Skip to content

[FEAT] COMPLETE 상태 추가#91

Merged
MeongW merged 97 commits intodevelopfrom
feature/chat
Aug 18, 2025
Merged

[FEAT] COMPLETE 상태 추가#91
MeongW merged 97 commits intodevelopfrom
feature/chat

Conversation

@leeedongjaee
Copy link
Contributor

@leeedongjaee leeedongjaee commented Aug 18, 2025

🚀 관련 이슈

#37

🔑 주요 변경사항

  • COMPLETE 상태 추가

✔️ 체크 리스트

  • Merge 하려는 브랜치가 올바른가? (main branch에 실수로 PR 생성 금지)
  • Merge 하려는 PR 및 Commit들을 로컬에서 실행했을 때 에러가 발생하지 않았는가?
  • 라벨을 등록했는가?
  • 리뷰어를 지정했는가?

📢 To Reviewers

📸 스크린샷 or 실행영상

↗️ 개선 사항

Summary by CodeRabbit

  • New Features
    • 최종 계약 요청/승인 엔드포인트 추가 및 완료 상태/완료 URL 지원
    • 계약서 합법성 점검 연동: 위반 사항을 대화로 안내
    • 실시간 접속 현황 방송 및 상대 미접속 시 메시지 전송 차단
    • WebSocket 인증 강화(헤더 기반) 및 사용자 지정 대상 메시징 활성화
    • 계약 채팅 URL에 homeId 파라미터 포함
  • Improvements
    • 보증금 협의 엔드포인트 명칭 정리(/price/request·/accept·/reject)
    • 단계 진행 안내 메시지 및 카피 개선
    • 역할 표시 문구 간결화

@coderabbitai
Copy link

coderabbitai bot commented Aug 18, 2025

Walkthrough

여러 채팅/계약 컴포넌트에 걸쳐 WebSocket 인증/프린시펄 설정, Redis 기반 프레즌스 관리, 계약 채팅 입장/메시지 처리 강화, 최종계약 요청/수락 API 추가, AI 적법성 점검 서비스/DTO 개편, URL 파라미터(homeId) 전파, 매퍼/SQL 서명 변경, 컨트롤러 엔드포인트 재구성 및 로깅 전환(@log4j2)이 포함되었습니다.

Changes

Cohort / File(s) Change Summary
WebSocket 인증/라우팅
src/main/java/org/scoula/global/websocket/config/WebSocketConfig.java
STOMP CONNECT 인터셉터 추가(Authorization/JWT 또는 X-User-Id로 Principal 설정), 브로커 대상 확장(/topic,/queue), user prefix(/user) 설정, Log4j2 로깅.
채팅 컨트롤러
src/main/java/org/scoula/domain/chat/controller/ContractChatControllerImpl.java
Lombok 로그 전환(@log4j2), enter 핸들러 null-세이프/추가 디버그 로깅, 역할 문자열 수정, 디버그 HTTP 엔드포인트 POST /{contractChatId}/debug/enter 추가.
채팅 서비스(일반)
src/main/java/org/scoula/domain/chat/service/ChatServiceImpl.java
계약 채팅 URL에 homeId 쿼리 파라미터 추가.
계약 채팅 서비스(코어)
src/main/java/org/scoula/domain/chat/service/ContractChatServiceImpl.java, .../ContractChatServiceInterface.java
Redis 기반 프레즌스 관리(입장/퇴장/브로드캐스트), 오프라인 수신자 차단 처리, 최종계약 요청/수락 흐름과 적법성 점검 연동, COMPLETE 상태 처리, AI 법률 메시지 전송 메서드 추가, 디버그 온라인 사용자 출력, 의존성 주입(ContractMongoRepository, ContractFixServiceInterface), 로깅 강화. 인터페이스에 requestFinalContract, acceptFinalContract 추가.
계약 상태/VO
src/main/java/org/scoula/domain/chat/vo/ContractChat.java
ContractStatusCOMPLETE 상수 추가.
계약 컨트롤러(API)
src/main/java/org/scoula/domain/contract/controller/ContractController.java, .../ContractControllerImpl.java
최종계약 요청/수락 엔드포인트 추가(Authentication 사용), 예외 처리/로깅, 일부 기존 메서드 ContractFixServiceInterface로 위임, 가격 관련 엔드포인트 경로 조정(/price/*).
적법성 DTO
src/main/java/org/scoula/domain/contract/dto/LegalityDTO.java
구조 개편: top-level success/message 제거, data/error/timestamp 도입, Payload 필드 정리(요약/권고 제거), 위반 리스트 기본값 지정.
계약 Mongo 저장소
src/main/java/org/scoula/domain/contract/repository/ContractMongoRepository.java
특약 저장 시 order 매핑 변경(그대로 사용), 특약 초기화 메서드 clearSpecialContracts 추가.
적법성 서비스(분리)
src/main/java/org/scoula/domain/contract/service/ContractFixServiceInterface.java, .../ContractFixService.java
새로운 서비스/인터페이스 추가: 사용자/소유자 검증, AI 서버 호출로 적법성 점검, 특약 저장 위임, 설정값(aiServerUrl) 사용.
계약 서비스(기존 인터페이스)
src/main/java/org/scoula/domain/contract/service/ContractService.java, .../ContractServiceImpl.java
인터페이스에서 적법성/특약 관련 메서드 대거 제거. 구현에서 단계 진행/메시지 문구/입금흐름 로직 재구성, Redis 저장 포맷 단순화, 의존성 재정렬 및 @lazy 주입.
프리컨트랙트 매퍼/SQL
src/main/java/org/scoula/domain/precontract/mapper/TenantPreContractMapper.java, .../service/PreContractDataServiceImpl.java, .../service/TenantPreContractServiceImpl.java, src/main/resources/.../TenantPreContractMapper.xml, .../OwnerPreContractMapper.xml
selectIdentityId 서명에 contractChatId 추가 및 SQL 필터 강화, selectRentTypeAll 추가, ChatRoom 조회 및 링크 메시지용 chatRoomId/URL(homeId 포함) 사용.
프리컨트랙트 서비스(소유자)
src/main/java/org/scoula/domain/precontract/service/OwnerPreContractServiceImpl.java
링크 메시지 후 Mongo 저장 호출 추가, 초기 상태 업데이트 제거.
서블릿 설정
src/main/java/org/scoula/global/config/ServletConfig.java
CORS 설정 주석 블록 추가(비활성, 동작 변화 없음).

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant WS as WebSocketConfig
  participant Broker as STOMP Broker

  Client->>WS: CONNECT (Authorization / X-User-Id)
  WS->>WS: Parse JWT 'sub' or fallback X-User-Id
  WS->>Broker: setUser(Principal)
  Broker-->>Client: CONNECTED
Loading
sequenceDiagram
  participant Owner
  participant CCtrl as ContractControllerImpl
  participant CChat as ContractChatServiceImpl
  participant Fix as ContractFixService
  participant Mongo as ContractMongoRepository
  participant Redis as Redis

  Owner->>CCtrl: POST /specialContract/final-request
  CCtrl->>CChat: requestFinalContract(chatId, ownerId)
  CChat->>Redis: set request lock / presence
  CChat->>Fix: getLegality(chatId, buyerId)
  Fix->>Mongo: load contract doc
  Fix->>AI: POST /api/contract/validate
  AI-->>Fix: LegalityDTO
  Fix-->>CChat: LegalityDTO
  CChat-->>Owner: 메시지/알림 (위반 또는 통과)
Loading
sequenceDiagram
  participant Buyer
  participant CCtrl as ContractControllerImpl
  participant CChat as ContractChatServiceImpl
  participant Mongo as ContractMongoRepository
  participant Redis as Redis

  Buyer->>CCtrl: POST /specialContract/final-accept
  CCtrl->>CChat: acceptFinalContract(chatId, buyerId, isAccepted)
  alt accepted
    CChat->>Mongo: clearSpecialContracts(), persist final
    CChat->>Redis: update status COMPLETE
    CChat-->>Buyer: 결과 Map(status=COMPLETE,…)
  else rejected
    CChat-->>Buyer: 결과 Map(accepted=false)
  end
Loading
sequenceDiagram
  participant User
  participant Ctrl as ContractChatControllerImpl
  participant Svc as ContractChatServiceImpl
  participant Redis as Redis
  participant Sub as Subscribers

  User->>Ctrl: enterContractChatRoom(payload)
  Ctrl->>Svc: enterContractChatRoom(chatId, userId)
  Svc->>Redis: add member / set current room
  Svc->>Sub: PRESENCE(ownerOnline, buyerOnline)
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Assessment against linked issues

Objective Addressed Explanation
계약 전 채팅 로직 구현, 채팅 API 구현 (#1)
채팅 리스트 API 구현 (#1) 변경 내역에서 리스트 API 추가/수정이 명확히 보이지 않음.
SSE 활용한 채팅 알림 구현 (#1) SSE 관련 추가/수정 없음. WebSocket/STOMP만 변경됨.

Assessment against linked issues: Out-of-scope changes

Code Change Explanation
최종계약 요청/수락 엔드포인트 추가 (src/main/java/org/scoula/domain/contract/controller/ContractController.java, .../ContractControllerImpl.java) 링크드 이슈의 범위(채팅 모듈, SSE/리스트) 밖의 계약 최종화 기능입니다.
적법성 점검 서비스/인터페이스 및 AI 연동 추가 (src/main/java/org/scoula/domain/contract/service/ContractFixService.java, .../ContractFixServiceInterface.java) 이슈에 명시된 요구사항에 없는 AI 적법성 검증 도메인입니다.
LegalityDTO 구조 개편 (src/main/java/org/scoula/domain/contract/dto/LegalityDTO.java) 채팅 모듈 구현과 직접 관련 없는 DTO 대규모 리팩터링입니다.
ContractMongoRepository 특약 초기화 메서드 추가 및 order 로직 변경 (src/main/java/.../ContractMongoRepository.java) 채팅 알림/리스트/API와 무관한 데이터 저장소 동작 변경입니다.
ContractService 인터페이스에서 적법성/특약 관련 메서드 제거 (src/main/java/.../ContractService.java) 채팅 모듈 이슈 범위를 넘어서는 서비스 계약 변경입니다.

Possibly related PRs

Suggested labels

✨ feature

Suggested reviewers

  • MeongW
  • Whatdoyumin

Poem

갸웃, 안테나 곤두세워 접속 완료!
PRESENCE 띄우고, 툭—법률도 살펴보죠.
집ID까지 URL에 꽁꽁 묶어,
계약의 골은 COMPLETE로 퐁퐁!
딸깍, 디버그 문 열리니—
오늘도 토끼는 로그에 둥실 춤춰요. 🐰✨

✨ Finishing Touches
  • 📝 Generate Docstrings
🧪 Generate unit tests
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feature/chat

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share
🪧 Tips

Chat

There are 3 ways to chat with CodeRabbit:

  • Review comments: Directly reply to a review comment made by CodeRabbit. Example:
    • I pushed a fix in commit <commit_id>, please review it.
    • Open a follow-up GitHub issue for this discussion.
  • Files and specific lines of code (under the "Files changed" tab): Tag @coderabbitai in a new review comment at the desired location with your query.
  • PR comments: Tag @coderabbitai in a new PR comment to ask questions about the PR branch. For the best results, please provide a very specific query, as very limited context is provided in this mode. Examples:
    • @coderabbitai gather interesting stats about this repository and render them as a table. Additionally, render a pie chart showing the language distribution in the codebase.
    • @coderabbitai read the files in the src/scheduler package and generate a class diagram using mermaid and a README in the markdown format.

Support

Need help? Create a ticket on our support page for assistance with any issues or questions.

CodeRabbit Commands (Invoked using PR/Issue comments)

Type @coderabbitai help to get the list of available commands.

Other keywords and placeholders

  • Add @coderabbitai ignore anywhere in the PR description to prevent this PR from being reviewed.
  • Add @coderabbitai summary to generate the high-level summary at a specific location in the PR description.
  • Add @coderabbitai anywhere in the PR title to generate the title automatically.

CodeRabbit Configuration File (.coderabbit.yaml)

  • You can programmatically configure CodeRabbit by adding a .coderabbit.yaml file to the root of your repository.
  • Please see the configuration documentation for more information.
  • If your editor has YAML language server enabled, you can add the path at the top of this file to enable auto-completion and validation: # yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json

Status, Documentation and Community

  • Visit our Status Page to check the current availability of CodeRabbit.
  • Visit our Documentation for detailed information on how to use CodeRabbit.
  • Join our Discord Community to get help, request features, and share feedback.
  • Follow us on X/Twitter for updates and announcements.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 9

🔭 Outside diff range comments (1)
src/main/java/org/scoula/domain/precontract/service/PreContractDataServiceImpl.java (1)

91-95: ownerMapper.selectIdentityId 단일 파라미터 호출 제거 및 2-파라미터 시그니처로 변경 필요

아직 ownerMapper.selectIdentityId(contractChatId) 형태의 단일 파라미터 호출이 남아 있습니다. 아래 위치들을 모두 두 파라미터(contractChatId, ownerId) 시그니처로 변경해 주세요:

  • src/main/java/org/scoula/domain/precontract/service/PreContractDataServiceImpl.java:50
  • src/main/java/org/scoula/domain/precontract/service/OwnerPreContractServiceImpl.java:138, 545
  • src/main/java/org/scoula/domain/precontract/mapper/OwnerPreContractMapper.java:41

매퍼 인터페이스 정의와 호출부 양쪽에서 모두 업데이트가 필요합니다.

🧹 Nitpick comments (34)
src/main/java/org/scoula/global/config/ServletConfig.java (1)

98-113: 주석 처리된 CORS 설정: 유지/삭제 의사결정 필요 및 설정 중앙화 제안

현재 addCorsMappings 블록이 전부 주석 처리되어 있어 동작에 영향은 없습니다. WebSocket 쪽은 setAllowedOriginPatterns로 별도 관리 중인데, REST API CORS가 필요한 환경이라면 여기서 명시적으로 관리하거나, Nginx 단으로 일원화해 주석 코드를 제거하는 편이 깔끔합니다. 또한 허용 오리진 목록은 환경별로 빈번히 바뀌므로 application.yml 기반 프로퍼티로 중앙화하는 것을 권장합니다.

원칙: CORS를 Nginx에서 전담한다면 본 블록은 삭제하시고, Spring에서 관리한다면 주석을 해제해 실제 적용으로 전환해주세요. 어느 쪽으로 정리할지 확인 부탁드립니다.

src/main/java/org/scoula/global/websocket/config/WebSocketConfig.java (1)

49-57: 허용 오리진 패턴 관리 일원화 제안

STOMP 엔드포인트에 대한 setAllowedOriginPatterns 설정은 적절합니다. 다만 동일한 오리진 목록이 REST CORS(ServletConfig)와 분리되어 관리되면 누락/불일치가 발생하기 쉽습니다. 환경변수/프로퍼티(itzeep.cors.allowed-origins)로 중앙화하거나, 리버스 프록시(Nginx)에서 일원화하면 운영 편의성이 올라갑니다.

프록시(Nginx)에서 CORS를 전담하는 정책이라면 Spring 쪽 오리진 목록은 최소화하시겠습니까?

src/main/java/org/scoula/domain/chat/vo/ContractChat.java (2)

37-55: COMPLETE에서 getCurrentRound()가 1L을 반환 — 비라운드 상태를 null로 명확히 구분하는 것을 고려해 주세요.

현재는 ROUND가 아닌 모든 상태(포함: COMPLETE)에서 1L을 반환합니다. 이는 “라운드 맥락 없음”을 “1라운드”로 오인하게 만들 수 있습니다. 비라운드 상태는 null로 분리하는 편이 호출부에서 의도를 더 명확히 표현할 수 있습니다.

다음과 같이 완만한 리팩터를 제안드립니다(호출부의 null 처리 필요).

-      if (status == null) return 1L;
+      if (status == null) return null;
       switch (status) {
         case ROUND0:
           return 1L;
         case ROUND1:
           return 2L;
         case ROUND2:
           return 3L;
         case ROUND3:
           return 4L;
         case ROUND4:
           return 5L;
         default:
-          return 1L;
+          return null;
       }

참고:

  • 만약 비즈니스 규칙상 COMPLETE를 “마지막 라운드로 간주”해야 한다면 default 대신 case COMPLETE:를 명시하고 적절한 값(예: 5L 또는 기대하는 라운드 인덱스)을 반환하도록 해 주세요.

21-21: 상태 판별 헬퍼 추가 제안: isComplete()

호출부 가독성과 중복 분기 제거를 위해 간단한 헬퍼를 두는 것을 권장합니다.

public boolean isComplete() {
    return status == ContractStatus.COMPLETE;
}

(필요하면 isFinalState() 등 도메인 용어에 맞춘 헬퍼도 함께 정리해 드릴 수 있습니다.)

src/main/resources/org/scoula/domain/precontract/mapper/OwnerPreContractMapper.xml (1)

145-147: 계약별 본인인증 스코프 강화 OK — contract_step 추가 필터 권장

iv.contract_id = cc.contract_chat_id 조건 추가로 범위가 정확해졌습니다. 동일 사용자·다중 단계(START 외) 레코드가 존재할 수 있다면 contract_step='START'까지 함께 거는 것이 더 안전합니다.

적용 예시:

     WHERE cc.contract_chat_id = #{contractChatId}
-    AND iv.contract_id = cc.contract_chat_id
+    AND iv.contract_id = cc.contract_chat_id
+    AND iv.contract_step = 'START'

추가 제안:

  • 성능을 위해 identity_verification(contract_id, user_id) 혹은 (user_id, contract_id, contract_step) 조합에 인덱스가 있는지도 확인 부탁드립니다.
src/main/java/org/scoula/domain/chat/service/ChatServiceImpl.java (1)

842-848: homeId 쿼리 파라미터 추가 좋습니다 — URI 빌더 사용으로 안전성/가독성 향상 권장

현재 문자열 연결은 동작하나, 쿼리 인코딩/구분자(?, &) 처리 리스크를 줄이기 위해 UriComponentsBuilder 사용을 권장합니다. 또한 BUYERURL은 경로와 쿼리를 혼합하고 있어 빌더로 명시적으로 분리하는 편이 안전합니다.

아래처럼 대체를 제안합니다:

-String contractChatUrl =
-        URL
-                + PRECONTRACTURL
-                + (contractChatRoomId.toString())
-                + BUYERURL
-                + "&homeId="
-                + (originalChatRoom.getHomeId());
+String contractChatUrl = UriComponentsBuilder.fromHttpUrl(URL)
+        .path(PRECONTRACTURL)
+        .path(contractChatRoomId.toString())
+        .path("/buyer")
+        .queryParam("step", 1)
+        .queryParam("homeId", originalChatRoom.getHomeId())
+        .build()
+        .toUriString();

추가 필요 import (파일 상단):

import org.springframework.web.util.UriComponentsBuilder;

유의:

  • originalChatRoom.getHomeId()가 null일 가능성이 있다면, 쿼리 파라미터 추가 전 null 가드가 필요합니다.
src/main/java/org/scoula/domain/chat/service/ContractChatServiceInterface.java (2)

308-310: Map 반환 대신 명시적 DTO 도입 고려

acceptFinalContract(...)Map<String, Object>를 반환하면 타입 안정성이 떨어집니다. 응답 스키마가 고정적이라면 DTO를 정의해 교체하는 것을 권장합니다. 예: FinalContractAcceptResultDto { status, contractChatId, nextStep, ... }


305-307: requestFinalContract vs requestFinalContractConfirmation – Javadoc로 역할 분리 필요

현재 두 메서드 모두 동일한 Javadoc(임대인이 최종 특약 확정 요청)을 사용해 개념이 혼동됩니다.
각 메서드가 담당하는 워크플로우(예: 계약서 생성 요청 vs. 확정 요청), 상태 전이 트리거(COMPLETE 포함 여부) 등을 Javadoc에 분리·명시해 주세요.
구현체인 ContractChatServiceImpl에는 이미 두 메서드가 모두 정의·구현되어 있으니, 동시 갱신 여부는 추가 조치 없이도 보장됩니다.

수정 대상:

  • src/main/java/org/scoula/domain/chat/service/ContractChatServiceInterface.java
    requestFinalContract(...) Javadoc – 신규 메서드 역할(특약서 생성/전송 등) 구체화
    requestFinalContractConfirmation(...) Javadoc – “확정 요청” 워크플로우 구분
src/main/java/org/scoula/domain/chat/controller/ContractChatControllerImpl.java (1)

306-326: 디버그 로깅 레벨 하향 권장

payload/principal/스레드명 등 상세 정보는 INFO보다 DEBUG가 적절합니다. 운영 로그 노이즈와 민감 정보 노출 위험을 줄이기 위해 레벨 조정 권장합니다.

-            log.info("=== WebSocket 계약 채팅방 입장 시작 ===");
-            log.info("payload: {}", payload);
-            log.info("principal: {}", principal != null ? principal.getName() : "null");
-            log.info("Thread: {}", Thread.currentThread().getName());
+            log.debug("=== WebSocket 계약 채팅방 입장 시작 ===");
+            log.debug("payload: {}", payload);
+            log.debug("principal: {}", principal != null ? principal.getName() : "null");
+            log.debug("Thread: {}", Thread.currentThread().getName());

-            log.info("추출된 userId: {}, contractChatId: {}", userId, contractChatId);
+            log.debug("추출된 userId: {}, contractChatId: {}", userId, contractChatId);

-            log.info("=== WebSocket 계약 채팅방 입장 완료 ===");
+            log.debug("=== WebSocket 계약 채팅방 입장 완료 ===");
src/main/java/org/scoula/domain/contract/dto/LegalityDTO.java (1)

20-22: 상위 스키마 확장(data/error/timestamp) — success/message 중복 노출 가능성 정리 권장

LegalityDTO 상위 레벨에 success, messagedata/error/timestamp가 공존하면, 외부 응답을 ApiResponse<LegalityDTO>로 감쌀 때 중첩·중복 필드로 혼란이 생길 수 있습니다. 한 가지 패턴으로 정리하는 것을 권장합니다.

  • 옵션 A: LegalityDTO는 순수 Payload 컨테이너(data/error/timestamp만)로 유지
  • 옵션 B: LegalityDTO 단독 응답이라면 success/message 유지, data를 평탄화

외부 영향이 적다면, 아래처럼 단일 패턴으로 정리해 보세요(참고용):

// 제안: success/message 제거하고 Payload 중심 유지
// 또는 @Deprecated로 마이그레이션 단계 표시

Swagger/문서 스키마도 함께 업데이트 해 주세요.

src/main/java/org/scoula/domain/precontract/service/OwnerPreContractServiceImpl.java (1)

443-446: saveContractMongo 호출 위치 및 예외 처리 보강 제안

검증 결과 ContractService.saveContractMongo(Long, Long) 시그니처는 문제없이 일치합니다.
하지만 URL 링크 전송(chatService.handleChatMessage) 이후 Mongo 저장 실패 시 사용자에게 이미 링크가 발송된 상태가 되어 흐름이 어색해질 수 있으니, 예외 처리를 보강하길 권장드립니다.

  • 대상 위치

    • 파일: src/main/java/org/scoula/domain/precontract/service/OwnerPreContractServiceImpl.java
      • 라인 443: contractService.saveContractMongo(contractChatId, userId);
  • 제안된 변경 (try-catch 추가)

-          contractService.saveContractMongo(contractChatId, userId);
+          try {
+              contractService.saveContractMongo(contractChatId, userId);
+          } catch (Exception e) {
+              log.error("계약 Mongo 저장 실패 - contractChatId: {}, userId: {}", contractChatId, userId, e);
+              throw new BusinessException(OwnerPreContractErrorCode.OWNER_INSERT, "계약 데이터 저장 실패", e);
+          }

또한 RDB 중심의 @Transactional 범위에 MongoTemplate 작업이 포함되지 않으므로, 강한 일관성이 필요하다면 outbox/event 기반 비동기 처리나 분산 트랜잭션 대안을 검토해 주세요.

src/main/resources/org/scoula/domain/precontract/mapper/TenantPreContractMapper.xml (2)

30-37: selectIdentityId: 불필요 조인 제거 또는 범위 제약 추가 권장

현재 쿼리는 contract_chat(cc)과 identity_verification(iv)를 조인하지만, cc에 대해 contract_chat_id 필터가 없어 실질적으로는 iv.contract_id 조건만으로 동작합니다. 선택지는 두 가지입니다.

  • 단순화(권장): cc 조인을 제거하고 iv만 조회
  • 보강: cc.contract_chat_id = #{contractChatId} 조건을 추가해 명시적 범위 제약

단순화 예시는 아래와 같습니다.

-        SELECT DISTINCT iv.identity_id
-        FROM contract_chat cc
-                 INNER JOIN identity_verification iv
-                            ON cc.buyer_id = iv.user_id
-        WHERE cc.buyer_id = #{userId}
-        AND iv.contract_id = #{contractChatId}
+        SELECT iv.identity_id
+        FROM identity_verification iv
+        WHERE iv.user_id = #{userId}
+          AND iv.contract_id = #{contractChatId}

262-268: 중복 기능 제거: selectRentTypeAll는 기존 selectRentType와 목적이 겹칩니다

이미 상단의 selectRentType가 contractChatId와 userId를 기준으로 lease_type을 반환합니다. selectRentTypeAll은 실질적으로 동일 목적(심지어 본 XML에서는 userId 미사용)이어서 API 표면적을 늘리고 혼동을 유발합니다. 본 메서드는 제거하고 기존 selectRentType를 재사용하는 방향을 권장합니다.

아래처럼 제거해 주세요.

-  <select id="selectRentTypeAll" resultType="string">
-    select h.lease_type
-    FROM contract_chat cc
-           INNER JOIN home h
-                      ON cc.home_id = h.home_id
-    WHERE cc.contract_chat_id = #{contractChatId}
-  </select>

원하시면 관련 Java 인터페이스와 호출부에서도 제거 패치를 도와드리겠습니다.

src/main/java/org/scoula/domain/contract/repository/ContractMongoRepository.java (1)

106-122: System.out.println 대신 로거 사용 및 일관된 로깅 체계로 교체 제안

Repository 클래스에서 표준 출력 사용은 운영 로그 수집/레벨 제어에 불리합니다. 프로젝트 전반의 @log4j2 사용에 맞춰 교체를 권장합니다.

변경 예시:

-          System.out.println("특약 내용 삭제 완료 - contractChatId: " + contractChatId);
+          log.info("특약 내용 삭제 완료 - contractChatId: {}", contractChatId);

추가로, 클래스에 로거 주입이 필요합니다(파일 상단 변경):

+import lombok.extern.log4j.Log4j2;
@@
-@Repository
+@Repository
+@Log4j2
 public class ContractMongoRepository {
src/main/java/org/scoula/domain/precontract/mapper/TenantPreContractMapper.java (1)

42-44: 중복 메서드 정리: selectRentTypeAll 제거를 권장

동일한 반환(lease_type)을 제공하는 selectRentType가 이미 존재합니다. selectRentTypeAll은 불필요한 중복으로 보이며 XML에도 신규 쿼리가 추가되었습니다. API 표면 축소를 위해 메서드/매퍼를 제거하고 기존 selectRentType만 유지하는 것을 권장합니다.

제거 예시:

-      Optional<String> selectRentTypeAll(
-              @Param("contractChatId") Long contractChatId, @Param("userId") Long userid);

XML의 해당 매퍼 블록도 함께 삭제해 주세요(별도 코멘트에 패치 예시 포함).

src/main/java/org/scoula/domain/contract/service/ContractFixServiceInterface.java (2)

5-10: 서비스 인터페이스 추가는 패턴에 부합합니다

서비스가 Interface/Impl 패턴을 따르도록 분리된 점 좋습니다. 이후 구현체 네이밍만 컨벤션에 맞추면 더 깔끔해질 것 같습니다.


7-7: 서비스 메서드 반환 타입은 void 권장 (Void 지양)

Void 박싱 타입은 항상 null을 반환해야 하므로 불필요한 널 처리/오해 소지가 있습니다. 부수효과만 있는 서비스 저장 메서드는 void가 적합합니다.

적용 diff:

-      Void saveSpecialContract(Long contractChatId, Long userId);
+      void saveSpecialContract(Long contractChatId, Long userId);
src/main/java/org/scoula/domain/contract/controller/ContractController.java (1)

122-130: accept 응답을 Map 대신 명시적 DTO로 반환하세요

Map은 스키마가 불분명해 API 문서화/호환성 유지가 어려워집니다. 명시적 DTO로 교체를 권장합니다. 또한 본문 유효성 검증을 위해 @Valid 추가를 검토해주세요.

예시 diff (시그니처 교체):

-      @ApiOperation(value = "최종 계약서 확정 수락 (임차인)", notes = "임차인이 임대인의 최종 특약서 확정 요청을 수락합니다.")
-      ResponseEntity<ApiResponse<Map<String, Object>>> acceptFinalContract(
-              @PathVariable Long contractChatId,
-              @RequestBody FinalContractDeletionResponseDto responseDto,
-              Authentication authentication);
+      @ApiOperation(value = "최종 계약서 확정 수락 (임차인)", notes = "임차인이 임대인의 최종 특약서 확정 요청을 수락합니다.")
+      ResponseEntity<ApiResponse<FinalContractAcceptResultDto>> acceptFinalContract(
+              @PathVariable Long contractChatId,
+              @Valid @RequestBody FinalContractDecisionRequestDto request,
+              Authentication authentication);

추가로 필요한 DTO 예시(별도 파일):

// FinalContractDecisionRequestDto.java
public class FinalContractDecisionRequestDto {
  @NotNull
  private Boolean accepted;
  // getter/setter
}

// FinalContractAcceptResultDto.java
public class FinalContractAcceptResultDto {
  private Long contractChatId;
  private String status; // e.g., "COMPLETE"
  private String message;
  // getter/setter, builder
}
src/main/java/org/scoula/domain/contract/service/ContractFixService.java (2)

24-27: 구현체 클래스명 컨벤션 미준수: ContractFixServiceImpl로 변경 권장

레포 컨벤션(Interface/Impl)에 맞추려면 구현체를 ContractFixServiceImpl로 이름짓는 것을 권장합니다. 빈 주입은 타입 기반이라 기능 영향은 없으나, 팀 컨벤션 일관성을 위해 정리해두면 좋습니다.

적용 diff:

-@Service
-@RequiredArgsConstructor
-@Log4j2
-public class ContractFixService implements ContractFixServiceInterface {
+@Service
+@RequiredArgsConstructor
+@Log4j2
+public class ContractFixServiceImpl implements ContractFixServiceInterface {

(파일명 및 기타 참조도 함께 변경 필요)


124-162: 검증 헬퍼의 접근 제한자 및 책임 범위 조정 제안

validateUserId, validateIsOwner는 외부에 노출할 필요가 없어 보입니다. 공개 범위를 private으로 줄여 외부 오용을 방지하고, 역할을 명확히 하세요.

적용 diff:

-      public void validateUserId(Long contractChatId, Long userId) {
+      private void validateUserId(Long contractChatId, Long userId) {
...
-      public void validateIsOwner(Long contractChatId, Long userId) {
+      private void validateIsOwner(Long contractChatId, Long userId) {

추가로, RestTemplate 타임아웃 설정(커넥션/소켓)과 ObjectMapper 빈 재사용을 도입하면 장애시 대기 현상 완화와 성능/메모리 사용 개선에 도움이 됩니다.

src/main/java/org/scoula/domain/contract/controller/ContractControllerImpl.java (1)

213-230: 최종 확정 ‘요청’ 엔드포인트: 권한(임대인) 검증이 서비스에서 수행되는지 확인 필요

컨트롤러 단에서는 인증만 확인하고 있어, 임대인만 호출 가능하도록 서비스 레이어에서 역할 검증이 수행되는지 확인 부탁드립니다. 없다면 추가하세요.

또한 예외 응답의 일관성을 위해 ApiResponse.error에 에러 코드/메시지 포맷을 통일하는 것도 고려해 주세요.

src/main/java/org/scoula/domain/contract/service/ContractServiceImpl.java (8)

7-7: @lazy 주입 도입 — 순환참조 해소 목적이면 OK, 아니라면 지양 권장

contractChatService에 @lazy를 적용해 순환참조를 회피한 것으로 보입니다. 실제 순환 구조가 없는데 초기화 지연만으로 문제를 덮으면 런타임에 늦게 드러나는 실패를 초래할 수 있습니다. 꼭 필요한 경우에만 유지하세요.

필요 시 순환 의존 경로를 알려주시면 구조적 개선(포트/어댑터로 분리, 이벤트 발행 등) 제안드리겠습니다.

Also applies to: 42-42


166-173: 문자열 상수는 private static final로 승격 권장

step3StartMessage는 불변/공유되는 상수입니다. 가시성 및 불변성 보장을 위해 static final로 선언하고 UPPER_SNAKE_CASE 네이밍을 권장합니다.

-    String step3StartMessage = "다음은 3단계: ‘특약 조율' 단계입니다.\n"
+    private static final String STEP3_START_MESSAGE = "다음은 3단계: ‘특약 조율' 단계입니다.\n"

그리고 사용처를 STEP3_START_MESSAGE로 교체하세요.


185-211: 중복 스텝 업데이트 및 Thread.sleep 사용은 제거/비동기화 권장

  • nextSteps(...)가 이미 두 사용자 수락 시 STEP1로 변경하는데 여기서 다시 STEP1로 업데이트하고 있습니다. 중복/경합 가능성이 있으며 불필요한 DB write입니다.
  • Thread.sleep으로 서비스 스레드를 블로킹하면 서버 처리량에 악영향을 줍니다. 메시지 지연 연출은 큐/스케줄러/프론트 타이머로 처리하는 게 안전합니다.

중복 업데이트 제거 예시:

-          // 스텝 변경
-          contractChatMapper.updateStatus(contractChatId, ContractChat.ContractStatus.STEP1);

지연은 @async, 이벤트 발행, 혹은 메시지 브로커/프론트 타이머로 대체를 권장합니다.


234-241: 전/월세 타입 매핑 견고성 개선

rentType 문자열 비교는 NPE와 대소문자 이슈에 취약합니다. Enum 사용 또는 equalsIgnoreCase로 보완하세요.

-          String rentTypeKr;
-          if(rentType.equals("JEONSE")){
-              rentTypeKr = "전세";
-          }else{
-              rentTypeKr="월세";
-          }
+          String rentTypeKr = "월세";
+          if ("JEONSE".equalsIgnoreCase(rentType)) {
+              rentTypeKr = "전세";
+          }

257-257: Thread.sleep(2000)로 서비스 블로킹 — 비동기/프론트 타이머로 대체 권장

서버 스레드 블로킹은 처리량 저하/타임아웃을 유발합니다. 메시지 간 딜레이는 클라이언트 타이머 또는 비동기 작업으로 연출하세요.

원하시면 @async 기반 또는 이벤트 발행 방식으로 변환 패치를 제안드리겠습니다.


278-309: Redis 저장 포맷/TTL/경합 처리 보완 필요

  • 단일 문자열 "deposit,monthly" 포맷은 확장/검증이 약합니다. Hash 또는 JSON 저장을 고려하세요.
  • TTL 미설정으로 스테일 데이터가 남을 수 있습니다.
  • 동일 키에 대한 동시 업데이트 경쟁을 고려해 setIfAbsent 또는 Lua 스크립트/분산락이 필요할 수 있습니다.

간단 개선 예시(TTL 추가):

-        stringRedisTemplate.opsForValue().set(redisKey, paymentValue);
+        stringRedisTemplate.opsForValue().set(redisKey, paymentValue, java.time.Duration.ofHours(6));

향후 확장을 고려해 opsForHash().putAll(...) 구조로 전환을 권장합니다.


640-669: formatWonShort 축약 규칙 명확화 및 long 시그니처 권장

  • 금액이 1만 미만일 때 7,500 → "7천원"으로 내림 처리됩니다. 요구사항이 반올림/표시 정밀도를 어떻게 요구하는지 명확화 필요.
  • 음수 금액, 매우 큰 금액(억 단위 초과 수십억) 케이스에 대한 명세/테스트가 있으면 좋습니다.
  • long 시그니처로 변경해 오버플로우 여지를 제거하세요.

원하시면 경계값 테스트(JUnit)와 함께 long 기반 구현으로 일괄 수정 PR 패치 드리겠습니다.


331-391: 금액 필드 타입 long으로 변경 검토

PaymentDTO, ContractDTO, ContractMongoDocument, AIMessageDTO 등에서 depositPrice/monthlyRentint로 선언하고 있어
2,147,483,647원을 초과하는 금액이 들어올 경우 오버플로우가 발생할 수 있습니다. 아래 항목을 검토해주세요:

  • src/main/java/org/scoula/domain/contract/dto/PaymentDTO.java,
    ContractDTO.java, ContractMongoDocument.java, AIMessageDTO.java 등 관련 DTO·문서 클래스의 필드 타입을 intlong으로 변경
  • repository.updateDepositPrice(...) 메서드 시그니처 및 호출부 수정
  • formatWonShort(long) 오버로드 또는 구현 검토
  • 변경에 따른 MongoDB 스키마, API 응답 스펙, 연관 VO/DTO/엔티티 영향 범위 확인

파싱 부분 예시:

-    int depositPrice = Integer.parseInt(amounts[0]);
-    int monthlyRent   = Integer.parseInt(amounts[1]);
+    long depositPrice = Long.parseLong(amounts[0]);
+    long monthlyRent  = Long.parseLong(amounts[1]);

광범위한 타입 변경이 필요하므로 영향 범위를 면밀히 검토하시기 바랍니다.

src/main/java/org/scoula/domain/chat/service/ContractChatServiceImpl.java (5)

497-513: REDIS 기반 입장 처리 및 PRESENCE 브로드캐스트 — 타당

방 멤버십을 Set으로 관리하고 역참조 키를 저장하는 구조는 명료합니다. 단, 비정상 종료로 leave가 호출되지 않는 경우를 대비해 userCurrentRoomKey에 짧은 TTL을 두는 것도 고려하세요.

- stringRedisTemplate.opsForValue().set(userCurrentRoomKey(userId), contractChatId.toString());
+ stringRedisTemplate.opsForValue().set(userCurrentRoomKey(userId), contractChatId.toString(), java.time.Duration.ofHours(4));

519-526: 온라인 상태의 이중 소스(REDIS + in-memory Map) 혼용 — 단일 소스로 일원화 권장

현재 isUserInContractChatRoom은 REDIS를, setContractChatUserOffline은 in-memory Map을 갱신합니다. 혼용 시 일관성 문제가 생깁니다. 온라인 상태 관리는 REDIS 단일 소스로 통일하고 in-memory Map 관련 메서드/필드를 제거하는 것이 깔끔합니다.

Also applies to: 629-638, 642-648


531-572: 디버그 로그 과다 — 운영 환경에서의 노이즈 최소화

현 시점의 상세 로그는 개발에 유용하나 운영에서는 과다합니다. log.isDebugEnabled() 가드 또는 로그 레벨 조정으로 노이즈를 줄이세요.

- log.info("=== getContractChatOnlineStatus(REDIS) 시작 ===");
+ log.debug("=== getContractChatOnlineStatus(REDIS) 시작 ===");

2478-2614: 트랜잭션 내 장시간 I/O와 Thread.sleep — 비동기화/워크플로우 분리 권장

acceptFinalContractConfirmation(...)에서

  • Mongo/Redis 업데이트 트랜잭션 범위 내에 Thread.sleep과 외부 AI 호출(법적 검토)이 포함돼 있습니다.

  • 이는 트랜잭션 지연/락 홀드/서버 스레드 고갈/타임아웃 리스크를 키웁니다.

  • 데이터 상태 변경(DB/Redis)은 빠르게 끝내고 커밋.

  • 법적 검토는 @async 또는 이벤트 발행(예: Spring 이벤트/메시지 브로커)로 비동기 실행.

  • 중간 메시지는 SSE/WebSocket으로 스트리밍하되 서버 스레드 블로킹은 피하세요(딜레이 연출은 클라이언트 타이머로).


107-126: 오프라인 시 메시지 전송 정책 UX/요건 재확인 필요

  • 현재 오프라인 상대에게 메시지를 저장하지 않고 전송을 차단한 뒤 에러만 반환하는 로직은 비동기 채팅/알림(FCM/SSE 등) 시나리오와 상충할 수 있습니다.
  • 일반적으로는 메시지를 DB에 저장하고, 오프라인 사용자에게 푸시 또는 알림을 발송하는 방식을 사용합니다.

제안:

  • 메시지를 항상 저장
  • 상대가 오프라인일 경우 NotificationServiceImpl.createChatNotification(...) 호출을 통해 푸시 알림 발송
  • 송신자에게는 에러 대신 “상대가 현재 오프라인입니다. 읽으면 알림 드릴게요.” 안내 메시지를 별도 채널로 전송

※ STOMP 설정(WebSocketConfig)에서 config.setUserDestinationPrefix("/user")가 적용되어 있어,
convertAndSendToUser(dto.getSenderId().toString(), "/queue/contract/error") 호출 방식과 정합성이 확인되었습니다.

📜 Review details

Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro

💡 Knowledge Base configuration:

  • MCP integration is disabled by default for public repositories
  • Jira integration is disabled by default for public repositories
  • Linear integration is disabled by default for public repositories

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between b5c9d35 and fa5904f.

📒 Files selected for processing (21)
  • src/main/java/org/scoula/domain/chat/controller/ContractChatControllerImpl.java (5 hunks)
  • src/main/java/org/scoula/domain/chat/service/ChatServiceImpl.java (1 hunks)
  • src/main/java/org/scoula/domain/chat/service/ContractChatServiceImpl.java (10 hunks)
  • src/main/java/org/scoula/domain/chat/service/ContractChatServiceInterface.java (1 hunks)
  • src/main/java/org/scoula/domain/chat/vo/ContractChat.java (1 hunks)
  • src/main/java/org/scoula/domain/contract/controller/ContractController.java (2 hunks)
  • src/main/java/org/scoula/domain/contract/controller/ContractControllerImpl.java (8 hunks)
  • src/main/java/org/scoula/domain/contract/dto/LegalityDTO.java (2 hunks)
  • src/main/java/org/scoula/domain/contract/repository/ContractMongoRepository.java (3 hunks)
  • src/main/java/org/scoula/domain/contract/service/ContractFixService.java (1 hunks)
  • src/main/java/org/scoula/domain/contract/service/ContractFixServiceInterface.java (1 hunks)
  • src/main/java/org/scoula/domain/contract/service/ContractService.java (0 hunks)
  • src/main/java/org/scoula/domain/contract/service/ContractServiceImpl.java (11 hunks)
  • src/main/java/org/scoula/domain/precontract/mapper/TenantPreContractMapper.java (2 hunks)
  • src/main/java/org/scoula/domain/precontract/service/OwnerPreContractServiceImpl.java (1 hunks)
  • src/main/java/org/scoula/domain/precontract/service/PreContractDataServiceImpl.java (1 hunks)
  • src/main/java/org/scoula/domain/precontract/service/TenantPreContractServiceImpl.java (4 hunks)
  • src/main/java/org/scoula/global/config/ServletConfig.java (1 hunks)
  • src/main/java/org/scoula/global/websocket/config/WebSocketConfig.java (3 hunks)
  • src/main/resources/org/scoula/domain/precontract/mapper/OwnerPreContractMapper.xml (1 hunks)
  • src/main/resources/org/scoula/domain/precontract/mapper/TenantPreContractMapper.xml (3 hunks)
💤 Files with no reviewable changes (1)
  • src/main/java/org/scoula/domain/contract/service/ContractService.java
🧰 Additional context used
📓 Path-based instructions (6)
src/main/resources/org/scoula/domain/*/mapper/*Mapper.xml

📄 CodeRabbit Inference Engine (CLAUDE.md)

src/main/resources/org/scoula/domain/*/mapper/*Mapper.xml: Place MyBatis XML mappers at src/main/resources/org/scoula/domain/*/mapper/*Mapper.xml
Name MyBatis XML mappers *Mapper.xml to match their corresponding *Mapper.java interfaces

Files:

  • src/main/resources/org/scoula/domain/precontract/mapper/OwnerPreContractMapper.xml
  • src/main/resources/org/scoula/domain/precontract/mapper/TenantPreContractMapper.xml
src/main/java/org/scoula/**/service/*Service{Interface,Impl}.java

📄 CodeRabbit Inference Engine (CLAUDE.md)

Use interface-implementation pattern for services: *ServiceInterface.java (interface) and *ServiceImpl.java (implementation)

Files:

  • src/main/java/org/scoula/domain/chat/service/ContractChatServiceInterface.java
  • src/main/java/org/scoula/domain/chat/service/ChatServiceImpl.java
  • src/main/java/org/scoula/domain/contract/service/ContractFixServiceInterface.java
  • src/main/java/org/scoula/domain/precontract/service/PreContractDataServiceImpl.java
  • src/main/java/org/scoula/domain/precontract/service/TenantPreContractServiceImpl.java
  • src/main/java/org/scoula/domain/precontract/service/OwnerPreContractServiceImpl.java
  • src/main/java/org/scoula/domain/contract/service/ContractServiceImpl.java
  • src/main/java/org/scoula/domain/chat/service/ContractChatServiceImpl.java
src/main/java/org/scoula/**/controller/*Controller{,Impl}.java

📄 CodeRabbit Inference Engine (CLAUDE.md)

Use interface-implementation pattern for controllers: *Controller.java (interface) and *ControllerImpl.java (implementation)

Files:

  • src/main/java/org/scoula/domain/contract/controller/ContractController.java
  • src/main/java/org/scoula/domain/contract/controller/ContractControllerImpl.java
  • src/main/java/org/scoula/domain/chat/controller/ContractChatControllerImpl.java
src/main/java/org/scoula/**/controller/**/*.java

📄 CodeRabbit Inference Engine (CLAUDE.md)

src/main/java/org/scoula/**/controller/**/*.java: All controller endpoints should return a consistent ApiResponse<T> wrapper
Add Swagger annotations to controller endpoints for API documentation

Files:

  • src/main/java/org/scoula/domain/contract/controller/ContractController.java
  • src/main/java/org/scoula/domain/contract/controller/ContractControllerImpl.java
  • src/main/java/org/scoula/domain/chat/controller/ContractChatControllerImpl.java
src/main/java/org/scoula/domain/*/repository/*Repository.java

📄 CodeRabbit Inference Engine (CLAUDE.md)

Place MongoDB repositories under src/main/java/org/scoula/domain/*/repository/*Repository.java

Files:

  • src/main/java/org/scoula/domain/contract/repository/ContractMongoRepository.java
src/main/java/org/scoula/domain/*/mapper/*Mapper.java

📄 CodeRabbit Inference Engine (CLAUDE.md)

src/main/java/org/scoula/domain/*/mapper/*Mapper.java: Define MyBatis mapper interfaces under src/main/java/org/scoula/domain/*/mapper/*Mapper.java
Annotate MyBatis mapper interfaces with @Mapper

Files:

  • src/main/java/org/scoula/domain/precontract/mapper/TenantPreContractMapper.java
🧠 Learnings (5)
📚 Learning: 2025-08-04T00:51:44.587Z
Learnt from: Whatdoyumin
PR: ITZEEP/backend#40
File: src/main/resources/org/scoula/domain/precontract/mapper/OwnerPreContractMapper.xml:188-192
Timestamp: 2025-08-04T00:51:44.587Z
Learning: In the ITZEEP backend codebase, the owner_precontract_check table uses contract_chat_id as both primary key (PK) and foreign key (FK), so selectOwnerPrecheckId correctly returns contract_chat_id for use in upsertRestoreScope operations. This is confirmed by the join pattern "LEFT JOIN owner_restore_scope ors ON opc.contract_chat_id = ors.owner_precheck_id" in the mapper files.

Applied to files:

  • src/main/resources/org/scoula/domain/precontract/mapper/OwnerPreContractMapper.xml
📚 Learning: 2025-08-04T00:51:44.587Z
Learnt from: Whatdoyumin
PR: ITZEEP/backend#40
File: src/main/resources/org/scoula/domain/precontract/mapper/OwnerPreContractMapper.xml:188-192
Timestamp: 2025-08-04T00:51:44.587Z
Learning: In the ITZEEP backend codebase, the owner_precontract_check table uses contract_chat_id as both primary key (PK) and foreign key (FK), so selectOwnerPrecheckId correctly returns contract_chat_id for use in upsertRestoreScope operations.

Applied to files:

  • src/main/resources/org/scoula/domain/precontract/mapper/OwnerPreContractMapper.xml
📚 Learning: 2025-08-12T03:59:30.512Z
Learnt from: CR
PR: ITZEEP/backend#0
File: CLAUDE.md:0-0
Timestamp: 2025-08-12T03:59:30.512Z
Learning: Applies to src/main/java/org/scoula/domain/*/repository/*Repository.java : Place MongoDB repositories under `src/main/java/org/scoula/domain/*/repository/*Repository.java`

Applied to files:

  • src/main/java/org/scoula/domain/contract/repository/ContractMongoRepository.java
📚 Learning: 2025-08-12T03:59:30.512Z
Learnt from: CR
PR: ITZEEP/backend#0
File: CLAUDE.md:0-0
Timestamp: 2025-08-12T03:59:30.512Z
Learning: Applies to src/main/java/org/scoula/**/service/*Service{Interface,Impl}.java : Use interface-implementation pattern for services: `*ServiceInterface.java` (interface) and `*ServiceImpl.java` (implementation)

Applied to files:

  • src/main/java/org/scoula/domain/contract/service/ContractFixServiceInterface.java
📚 Learning: 2025-08-12T03:59:30.512Z
Learnt from: CR
PR: ITZEEP/backend#0
File: CLAUDE.md:0-0
Timestamp: 2025-08-12T03:59:30.512Z
Learning: Applies to src/main/java/org/scoula/**/controller/*Controller{,Impl}.java : Use interface-implementation pattern for controllers: `*Controller.java` (interface) and `*ControllerImpl.java` (implementation)

Applied to files:

  • src/main/java/org/scoula/domain/contract/controller/ContractControllerImpl.java
🧬 Code Graph Analysis (4)
src/main/java/org/scoula/global/websocket/config/WebSocketConfig.java (1)
src/main/java/org/scoula/global/auth/config/SecurityConfig.java (1)
  • Configuration (32-162)
src/main/java/org/scoula/domain/chat/controller/ContractChatControllerImpl.java (2)
src/main/java/org/scoula/domain/chat/controller/ChatControllerImpl.java (1)
  • RestController (30-466)
src/main/java/org/scoula/domain/chat/fcm/FCMController.java (1)
  • RestController (12-75)
src/main/java/org/scoula/domain/contract/dto/LegalityDTO.java (3)
src/main/java/org/scoula/domain/contract/dto/ContractDTO.java (1)
  • Data (57-65)
src/main/java/org/scoula/domain/contract/document/ContractMongoDocument.java (1)
  • Data (66-74)
src/main/java/org/scoula/domain/contract/dto/SpecialContractUpdateDTO.java (1)
  • Data (19-27)
src/main/java/org/scoula/domain/chat/service/ContractChatServiceImpl.java (5)
src/main/java/org/scoula/global/common/exception/BusinessException.java (1)
  • BusinessException (4-21)
src/main/java/org/scoula/global/common/exception/EntityNotFoundException.java (1)
  • EntityNotFoundException (4-13)
src/main/java/org/scoula/domain/contract/service/ContractFixService.java (1)
  • Service (24-163)
src/main/java/org/scoula/domain/chat/fcm/FCMService.java (1)
  • Service (19-80)
src/main/java/org/scoula/domain/chat/service/NotificationServiceImpl.java (1)
  • Service (25-367)
🪛 GitHub Actions: Security Scan
src/main/java/org/scoula/domain/chat/service/ContractChatServiceImpl.java

[error] 2914-2914: method broadcastPresence(Long) is already defined in class ContractChatServiceImpl


[error] 2935-2935: method requestFinalContract(Long,Long) is already defined in class ContractChatServiceImpl


[error] 2965-2965: method acceptFinalContract(Long,Long,Boolean) is already defined in class ContractChatServiceImpl

🪛 GitHub Actions: CI
src/main/java/org/scoula/domain/chat/service/ContractChatServiceImpl.java

[error] 2914-2914: method broadcastPresence(Long) is already defined in class ContractChatServiceImpl.


[error] 2935-2935: method requestFinalContract(Long,Long) is already defined in class ContractChatServiceImpl.


[error] 2965-2965: method acceptFinalContract(Long,Long,Boolean) is already defined in class ContractChatServiceImpl.

🔇 Additional comments (22)
src/main/java/org/scoula/global/websocket/config/WebSocketConfig.java (1)

36-41: 메시지 브로커 확장 및 사용자 프리픽스 추가 적절

/queue 추가와 /user 프리픽스 설정으로 개인 대상 메시징과 1:1 응답 패턴을 지원하게 됩니다. 현재 채팅/알림 요구사항과 정합성이 좋습니다.

src/main/java/org/scoula/domain/chat/vo/ContractChat.java (2)

57-65: isInRound()에서 COMPLETE를 라운드로 취급하지 않는 처리 — 의도에 부합한다면 문제 없습니다.

라운드 계열(ROUND0~4)만 true를 반환하고 COMPLETE는 제외됩니다. COMPLETE를 “협상 라운드 바깥의 종결 상태”로 정의한다면 현재 구현은 적절합니다.


24-35: 추가 검증 필요: Enum 매핑 방식 및 STEP3 의도 확인

스크립트 실행 결과를 토대로 아래 사항만 최종 점검해 주세요:

  • @Enumerated(EnumType.ORDINAL) 또는 EnumOrdinalTypeHandler 미발견
    → 영속 계층에서 EnumType.STRING(@Enumerated(EnumType.STRING)) 사용 여부,
    MyBatis 사용 시 VARCHAR 컬럼 + EnumTypeHandler 적용 여부를 반드시 확인
  • case COMPLETE: 분기 존재 (src/main/java/org/scoula/domain/chat/service/ContractChatServiceImpl.java:2093)
  • STEP3 레퍼런스 전무
    → 도메인 설계상 “STEP3=협상(ROUND) 구간”으로 의도적 생략인지 재확인
src/main/java/org/scoula/domain/chat/controller/ContractChatControllerImpl.java (4)

7-7: Null-safe 비교를 위한 Objects import 추가 적절합니다


34-39: 로깅 프레임워크를 Log4j2로 통일한 변경 적절합니다

프로젝트 전반(@log4j2)과 일관성 유지되어 좋습니다.


437-437: 역할 문자열 계산 로직 개선(LGTM)

Objects.equals(...)로 NPE 방지하며 간결합니다.


957-973: 디버그용 엔드포인트 보안 강화 필요

디버그용 임시 엔드포인트가 운영 환경에 그대로 노출되면 중요한 공격 표면이 될 수 있습니다. 아래 조치를 검토·적용해 주세요:

  • 접근 제어
    if (!contractChatService.isUserInContractChat(contractChatId, userId)) {
        return ResponseEntity.status(HttpStatus.FORBIDDEN)
            .body(ApiResponse.error("ACCESS_DENIED", "해당 채팅방에 대한 접근 권한이 없습니다."));
    }
  • 로깅 레벨 변경
    - log.info("=== HTTP 디버그 계약 채팅방 입장 ===");
    - log.info("contractChatId: {}, userId: {}", contractChatId, userId);
    + log.debug("=== HTTP 디버그 계약 채팅방 입장 ===");
    + log.debug("contractChatId: {}, userId: {}", contractChatId, userId);
  • Swagger 문서 숨김
    • springfox 사용 시: @ApiIgnore (import springfox.documentation.annotations.ApiIgnore)
    • springdoc(OpenAPI3) 사용 시: @Hidden (import io.swagger.v3.oas.annotations.Hidden)
  • 환경 제한
    @Profile("dev")              // 또는 @ConditionalOnProperty("app.debug.enabled")
    @ApiIgnore                   // 또는 @Hidden
    @PostMapping("/{contractChatId}/debug/enter")
    public ResponseEntity<…> debugEnterContractChatRoom(…) { … }

위 변경 후 반드시
• 프로파일이 ‘dev’가 아닐 때 해당 엔드포인트가 호출되지 않는지
• Swagger 문서에 노출되지 않는지
• 실제 채팅룸 멤버만 입장 가능한지
확인 부탁드립니다.

src/main/java/org/scoula/domain/contract/dto/LegalityDTO.java (2)

38-38: violations 기본값 초기화(LGTM)

@Builder.Default로 NPE 리스크를 줄였습니다.


44-48: 어노테이션 정리(LGTM)

내부 클래스에 @Data/@builder 조합 일관 유지되어 있습니다.

src/main/java/org/scoula/domain/contract/repository/ContractMongoRepository.java (1)

89-97: 특약 order 0-베이스 저장 변경 — 다운스트림 영향 검증 필요

백엔드에서 ContractMongoRepository.java(89–97행) 의 .order(fc.getOrder())로 0-베이스 저장하도록 수정되었고,
ContractDTO, SpecialContractDocument, ContractChatServiceImpl 등 응답/내부 DTO 전반에도 +1 보정 로직이 전혀 없습니다.

따라서,

  • 프런트엔드 또는 외부 API 소비부가 여전히 1-베이스(1번부터)로 특약 순번을 기대하는지
  • API 문서·스펙에서 order 필드가 1-베이스로 정의되어 있는지

를 반드시 확인하고, 필요시 소비부 보정 로직 추가 또는 API 스펙 업데이트를 해주세요.

src/main/java/org/scoula/domain/precontract/mapper/TenantPreContractMapper.java (1)

26-29: selectIdentityId 서명 변경 반영 필요 — 잔존 호출부 검토 및 수정 요망

아래 항목들을 반영해 주세요:

  • OwnerPreContractMapper의 메소드 정의도 2-파라미터(contractChatId, userId) 시그니처로 업데이트
    - Optional<Long> selectIdentityId(@Param("contractChatId") Long contractChatId);
    + Optional<Long> selectIdentityId(
    +     @Param("contractChatId") Long contractChatId,
    +     @Param("userId") Long userId);
  • 다음 호출부에서 userId 인자를 추가하여 호출하도록 수정
    • src/main/java/org/scoula/domain/precontract/service/PreContractDataServiceImpl.java:50
      - Long identityId = ownerMapper.selectIdentityId(contractChatId).orElse(null);
      + Long identityId = ownerMapper
      +     .selectIdentityId(contractChatId, ownerId).orElse(null);
    • src/main/java/org/scoula/domain/precontract/service/OwnerPreContractServiceImpl.java:138
      - .selectIdentityId(contractChatId)
      + .selectIdentityId(contractChatId, userId)
    • src/main/java/org/scoula/domain/precontract/service/OwnerPreContractServiceImpl.java:545
      - Long identityId = ownerMapper.selectIdentityId(contractChatId).orElse(null);
      + Long identityId = ownerMapper
      +     .selectIdentityId(contractChatId, userId).orElse(null);
  • 관련 MyBatis 매퍼 XML(SQL)도 파라미터 매핑(#{userId}) 추가

위 변경사항을 반영하면 구(단일 파라미터) 호출은 모두 제거됩니다.

Likely an incorrect or invalid review comment.

src/main/java/org/scoula/domain/precontract/service/TenantPreContractServiceImpl.java (1)

88-91: 계약 스코프 기반 본인인증 id 조회 변경, 적절합니다

selectIdentityId(contractChatId, userId)로 스코프를 좁힌 점 좋아요. 다중 채팅 방/계약 환경에서 식별자 혼선을 줄일 수 있습니다.

src/main/java/org/scoula/domain/contract/controller/ContractControllerImpl.java (2)

98-126: 가격 엔드포인트 리소스 경로 리팩터링 👍

요청/거절/수락을 명시적으로 구분한 REST 경로가 가독성과 의도를 더 잘 드러냅니다. 기존 클라이언트가 있다면 경로 변경에 따른 호환성 영향만 점검해 주세요.


129-148: 특약 저장 흐름에서 FixService 사용으로 의존성 분리가 개선되었습니다

적법성/특약 관련 로직이 ContractFixServiceInterface로 이동해 관심사 분리가 좋아졌습니다. 컨트롤러는 얇아지고 테스트 용이성이 향상됩니다.

src/main/java/org/scoula/domain/contract/service/ContractServiceImpl.java (2)

140-148: 초기 안내 메시지/STEP0 설정 변경 — 콘텐츠 및 흐름 변경은 타당

환영 메시지와 정보확인 안내 카피가 자연스럽고, STEP0로 전환 후 다음 안내 버튼 메시지 흐름도 일관됩니다. 별도 이슈 없습니다.

Also applies to: 156-162


247-254: 금액/안내 메시지 포맷 업데이트 — 가독성 좋아졌습니다

임대차 유형 한글 표기와 금액 축약 포맷 적용으로 메시지 가독성이 개선되었습니다. OK.

Also applies to: 263-266

src/main/java/org/scoula/domain/chat/service/ContractChatServiceImpl.java (6)

20-23: 법적 검토/최종계약 의존성 주입 — OK

LegalityDTO/ContractMongoRepository/ContractFixServiceInterface 주입은 신규 플로우 요구사항에 부합합니다. 문제 없습니다.

Also applies to: 49-53


203-218: 법률 위반 메시지 전용 채널(AiMessageLegal) 추가 — 목적 분리 좋습니다

법률 안내 메시지를 별도 senderId로 구분하는 접근은 프론트에서 스타일/필터링에 유용합니다. OK.


655-665: REDIS 키 설계/조회 — 간결하고 명확, LGTM

키 네이밍과 조회 로직이 직관적입니다. 별도 이슈 없습니다.

Also applies to: 668-678


2093-2095: COMPLETE 상태 파라미터 매핑 추가 — OK(프론트 정합성 확인)

COMPLETE를 "?step=3&round=4"로 매핑했습니다. 다만 getContractChatRoomUrl에서는 /complete/{id}로 리다이렉트하므로, 프론트가 두 경로를 모두 이해하는지 확인하세요.


2811-2816: COMPLETE 전용 URL(/contract/complete/{id}) 리다이렉트 — 프론트 라우팅 확인 필요

새 경로가 프론트 라우터에 정의되어 있는지, 기존 공유 링크/북마크와 충돌 없는지 확인 바랍니다. 필요 시 기존 파라미터 방식도 fallback으로 유지할 수 있습니다.


2818-2912: 중복 메서드 정의 없음 확인

broadcastPresence는 구현체(ContractChatServiceImpl)에만,
requestFinalContractacceptFinalContract는 인터페이스(ContractChatServiceInterface) 선언부와 구현체에 각각 하나씩만 존재합니다.
같은 클래스나 같은 타입 범위 내에서 중복 정의된 메서드는 없습니다.

따라서 중복 정의 관련 추가 조치는 필요하지 않습니다.

Comment on lines +122 to +124
messagingTemplate.convertAndSendToUser(
dto.getSenderId().toString(), "/queue/contract/error", errorInfo);

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Verification agent

🧩 Analysis chain

convertAndSendToUser의 userId 사용 — Principal 매핑 확인 필요

convertAndSendToUser의 첫 매개변수는 기본적으로 Principal 이름과 매핑됩니다. 현재 senderId.toString()을 사용 중인데, 세션 Principal 이름과 일치하지 않으면 라우팅이 안 됩니다. 설정을 확인하세요.

점검 스크립트(Principal 이름/유저 프리픽스):


🏁 Script executed:

#!/bin/bash
rg -n -C3 -P '(setUserDestinationPrefix|setApplicationDestinationPrefixes|SimpUserRegistry|Principal)' --type=java

Length of output: 92974


🏁 Script executed:

#!/bin/bash
rg -nC5 "convertAndSendToUser" src/main/java/org/scoula/domain/chat/service/ContractChatServiceImpl.java

Length of output: 544


convertAndSendToUser에 Principal Name 사용 필요
Spring WebSocket의 convertAndSendToUser는 첫 번째 인자로 세션의 Principal.getName()과 매핑되는 값을 사용합니다. 현재 dto.getSenderId().toString()(숫자 ID)을 전달하고 있어, 실제 WebSocket 연결 시 설정된 Principal Name(예: 이메일)과 일치하지 않으면 메시지가 라우팅되지 않습니다.

점검 및 수정 사항:

  • src/main/java/org/scoula/domain/chat/service/ContractChatServiceImpl.java:122
    • 기존
      messagingTemplate.convertAndSendToUser(
          dto.getSenderId().toString(), "/queue/contract/error", errorInfo);
    • 변경 제안
      - dto.getSenderId().toString()
      + principalName  // 예: principal.getName() 또는 DTO에 담긴 사용자 이메일
  • WebSocketConfig에서 setUserDestinationPrefix("/user")로 설정되어 있으므로, 클라이언트의 구독 경로(/user/queue/contract/error)가 Principal Name과 일치하는지 확인

Comment on lines +2818 to +2836
private void broadcastPresence(Long contractChatId) {
ContractChat c = contractChatMapper.findByContractChatId(contractChatId);
if (c == null) return;

boolean ownerIn = isUserInContractChatRoom(c.getOwnerId(), contractChatId);
boolean buyerIn = isUserInContractChatRoom(c.getBuyerId(), contractChatId);
boolean both = ownerIn && buyerIn;

Map<String, Object> payload =
Map.of(
"type", "PRESENCE",
"ownerInContractRoom", ownerIn,
"buyerInContractRoom", buyerIn,
"bothInRoom", both,
"canChat", both,
"ownerId", c.getOwnerId(),
"buyerId", c.getBuyerId());
messagingTemplate.convertAndSend("/topic/contract-chat/" + contractChatId, payload);
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

중복 메서드 정의로 컴파일 실패 — broadcastPresence(Long) 중복 제거 필요

CI가 "method broadcastPresence(Long) is already defined"로 실패했습니다. 기존 동일 시그니처 메서드가 클래스 내에 존재합니다. 아래 신규 정의를 제거하세요.

-      private void broadcastPresence(Long contractChatId) {
-          ContractChat c = contractChatMapper.findByContractChatId(contractChatId);
-          if (c == null) return;
-
-          boolean ownerIn = isUserInContractChatRoom(c.getOwnerId(), contractChatId);
-          boolean buyerIn = isUserInContractChatRoom(c.getBuyerId(), contractChatId);
-          boolean both = ownerIn && buyerIn;
-
-          Map<String, Object> payload =
-                  Map.of(
-                          "type", "PRESENCE",
-                          "ownerInContractRoom", ownerIn,
-                          "buyerInContractRoom", buyerIn,
-                          "bothInRoom", both,
-                          "canChat", both,
-                          "ownerId", c.getOwnerId(),
-                          "buyerId", c.getBuyerId());
-          messagingTemplate.convertAndSend("/topic/contract-chat/" + contractChatId, payload);
-      }

중복 제거 후, enter/leave에서 호출하는 참조는 기존 메서드를 사용하게 됩니다.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
private void broadcastPresence(Long contractChatId) {
ContractChat c = contractChatMapper.findByContractChatId(contractChatId);
if (c == null) return;
boolean ownerIn = isUserInContractChatRoom(c.getOwnerId(), contractChatId);
boolean buyerIn = isUserInContractChatRoom(c.getBuyerId(), contractChatId);
boolean both = ownerIn && buyerIn;
Map<String, Object> payload =
Map.of(
"type", "PRESENCE",
"ownerInContractRoom", ownerIn,
"buyerInContractRoom", buyerIn,
"bothInRoom", both,
"canChat", both,
"ownerId", c.getOwnerId(),
"buyerId", c.getBuyerId());
messagingTemplate.convertAndSend("/topic/contract-chat/" + contractChatId, payload);
}
🤖 Prompt for AI Agents
In src/main/java/org/scoula/domain/chat/service/ContractChatServiceImpl.java
around lines 2818 to 2836, there is a duplicate method definition
broadcastPresence(Long) causing a compile error; remove this newly added
duplicate method block (the entire method at these lines) so only the original
broadcastPresence(Long) remains, and verify the enter/leave callers reference
the existing method (no code changes to callers required if they already call
broadcastPresence(contractChatId)); commit the deletion and run a build to
confirm the duplicate definition error is resolved.

Comment on lines +2838 to +2866
@Override
public void requestFinalContract(Long contractChatId, Long ownerId) {
ContractChat contractChat = contractChatMapper.findByContractChatId(contractChatId);
if (contractChat == null) {
throw new EntityNotFoundException("계약 채팅방을 찾을 수 없습니다: " + contractChatId);
}

if (!ownerId.equals(contractChat.getOwnerId())) {
throw new BusinessException(ChatErrorCode.CHAT_ROOM_ACCESS_DENIED);
}

Optional<FinalSpecialContractDocument> finalContractOpt =
specialContractMongoRepository.findFinalContractByContractChatId(contractChatId);

if (finalContractOpt.isEmpty()) {
throw new IllegalArgumentException("최종 특약서가 생성되지 않았습니다.");
}

AiMessageBtn(contractChatId, "임대인이 최종 계약서 확인을 요청하였습니다");

String key = "final-contract:request:" + contractChatId;
String existingValue = stringRedisTemplate.opsForValue().get(key);
if (existingValue != null) {
throw new BusinessException(
ChatErrorCode.CONTRACT_END_REQUEST_ALREADY_EXISTS, "이미 확정 요청이 진행 중입니다.");
}
String value = ownerId.toString();
stringRedisTemplate.opsForValue().set(key, value);
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

중복 메서드 정의로 컴파일 실패 — requestFinalContract(Long, Long) 중복 제거 필요

동일 시그니처의 메서드가 이미 존재합니다. 아래 신규 정의를 삭제하세요.

-      @Override
-      public void requestFinalContract(Long contractChatId, Long ownerId) {
-          ContractChat contractChat = contractChatMapper.findByContractChatId(contractChatId);
-          if (contractChat == null) {
-              throw new EntityNotFoundException("계약 채팅방을 찾을 수 없습니다: " + contractChatId);
-          }
-          if (!ownerId.equals(contractChat.getOwnerId())) {
-              throw new BusinessException(ChatErrorCode.CHAT_ROOM_ACCESS_DENIED);
-          }
-          Optional<FinalSpecialContractDocument> finalContractOpt =
-                  specialContractMongoRepository.findFinalContractByContractChatId(contractChatId);
-          if (finalContractOpt.isEmpty()) {
-              throw new IllegalArgumentException("최종 특약서가 생성되지 않았습니다.");
-          }
-          AiMessageBtn(contractChatId, "임대인이 최종 계약서 확인을 요청하였습니다");
-          String key = "final-contract:request:" + contractChatId;
-          String existingValue = stringRedisTemplate.opsForValue().get(key);
-          if (existingValue != null) {
-              throw new BusinessException(
-                      ChatErrorCode.CONTRACT_END_REQUEST_ALREADY_EXISTS, "이미 확정 요청이 진행 중입니다.");
-          }
-          String value = ownerId.toString();
-          stringRedisTemplate.opsForValue().set(key, value);
-      }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
@Override
public void requestFinalContract(Long contractChatId, Long ownerId) {
ContractChat contractChat = contractChatMapper.findByContractChatId(contractChatId);
if (contractChat == null) {
throw new EntityNotFoundException("계약 채팅방을 찾을 수 없습니다: " + contractChatId);
}
if (!ownerId.equals(contractChat.getOwnerId())) {
throw new BusinessException(ChatErrorCode.CHAT_ROOM_ACCESS_DENIED);
}
Optional<FinalSpecialContractDocument> finalContractOpt =
specialContractMongoRepository.findFinalContractByContractChatId(contractChatId);
if (finalContractOpt.isEmpty()) {
throw new IllegalArgumentException("최종 특약서가 생성되지 않았습니다.");
}
AiMessageBtn(contractChatId, "임대인이 최종 계약서 확인을 요청하였습니다");
String key = "final-contract:request:" + contractChatId;
String existingValue = stringRedisTemplate.opsForValue().get(key);
if (existingValue != null) {
throw new BusinessException(
ChatErrorCode.CONTRACT_END_REQUEST_ALREADY_EXISTS, "이미 확정 요청이 진행 중입니다.");
}
String value = ownerId.toString();
stringRedisTemplate.opsForValue().set(key, value);
}
// <remove the duplicated requestFinalContract(Long, Long) method (lines 2838–2866)>
🤖 Prompt for AI Agents
In src/main/java/org/scoula/domain/chat/service/ContractChatServiceImpl.java
around lines 2838 to 2866 there is a duplicate definition of
requestFinalContract(Long, Long) causing a compile error; remove this newly
added method block entirely (the one shown in the diff) so only the original
single implementation remains, then recompile and run tests to ensure no
references or behavior differ — if any callers depended on logic unique to this
duplicate, merge those changes into the remaining method before deleting.

Comment on lines +2868 to 2912
@Override
public Map<String, Object> acceptFinalContract(
Long contractChatId, Long buyerId, Boolean isAccepted) {
if (!isUserInContractChat(contractChatId, buyerId)) {
throw new BusinessException(ChatErrorCode.CHAT_ROOM_ACCESS_DENIED);
}

ContractChat contractChat = contractChatMapper.findByContractChatId(contractChatId);
if (contractChat == null) {
throw new EntityNotFoundException("계약 채팅방을 찾을 수 없습니다: " + contractChatId);
}

Long ownerId = contractChat.getOwnerId();

if (!buyerId.equals(contractChat.getBuyerId())) {
throw new BusinessException(
ChatErrorCode.CHAT_ROOM_ACCESS_DENIED, "임차인만 확정 수락을 할 수 있습니다.");
}

String redisKey = "final-contract:request:" + contractChatId;
String storedOwnerId = stringRedisTemplate.opsForValue().get(redisKey);

if (storedOwnerId == null) {
throw new BusinessException(
ChatErrorCode.CONTRACT_END_REQUEST_NOT_FOUND, "확정 요청이 존재하지 않습니다.");
}

if (!storedOwnerId.equals(ownerId.toString())) {
throw new BusinessException(
ChatErrorCode.CONTRACT_END_REQUEST_INVALID, "확정 요청 정보가 유효하지 않습니다.");
}

stringRedisTemplate.delete(redisKey);

if (isAccepted) {
contractMongoRepository.clearSpecialContracts(contractChatId);
contractMongoRepository.saveSpecialContract(contractChatId);
contractChatMapper.updateStatus(contractChatId, ContractChat.ContractStatus.COMPLETE);
AiMessage(contractChatId, "임차인이 최종 계약서를 수락했습니다! 계약서 서명하러 갈께요!");
} else {
AiMessage(contractChatId, "임차인이 최종 계약서를 거절했습니다. 추가 협상이 필요합니다.");
}

return baseUrl + contractChatUrl + contractChatRoomId.toString() + param;
return Map.of("accepted", isAccepted);
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

중복 메서드 정의로 컴파일 실패 — acceptFinalContract(Long, Long, Boolean) 중복 제거 필요

동일 시그니처의 메서드가 이미 클래스에 존재합니다. 신규 정의를 삭제해 CI 에러를 해소하세요.

-      @Override
-      public Map<String, Object> acceptFinalContract(
-              Long contractChatId, Long buyerId, Boolean isAccepted) {
-          if (!isUserInContractChat(contractChatId, buyerId)) {
-              throw new BusinessException(ChatErrorCode.CHAT_ROOM_ACCESS_DENIED);
-          }
-          ContractChat contractChat = contractChatMapper.findByContractChatId(contractChatId);
-          if (contractChat == null) {
-              throw new EntityNotFoundException("계약 채팅방을 찾을 수 없습니다: " + contractChatId);
-          }
-          Long ownerId = contractChat.getOwnerId();
-          if (!buyerId.equals(contractChat.getBuyerId())) {
-              throw new BusinessException(
-                      ChatErrorCode.CHAT_ROOM_ACCESS_DENIED, "임차인만 확정 수락을 할 수 있습니다.");
-          }
-          String redisKey = "final-contract:request:" + contractChatId;
-          String storedOwnerId = stringRedisTemplate.opsForValue().get(redisKey);
-          if (storedOwnerId == null) {
-              throw new BusinessException(
-                      ChatErrorCode.CONTRACT_END_REQUEST_NOT_FOUND, "확정 요청이 존재하지 않습니다.");
-          }
-          if (!storedOwnerId.equals(ownerId.toString())) {
-              throw new BusinessException(
-                      ChatErrorCode.CONTRACT_END_REQUEST_INVALID, "확정 요청 정보가 유효하지 않습니다.");
-          }
-          stringRedisTemplate.delete(redisKey);
-          if (isAccepted) {
-              contractMongoRepository.clearSpecialContracts(contractChatId);
-              contractMongoRepository.saveSpecialContract(contractChatId);
-              contractChatMapper.updateStatus(contractChatId, ContractChat.ContractStatus.COMPLETE);
-              AiMessage(contractChatId, "임차인이 최종 계약서를 수락했습니다! 계약서 서명하러 갈께요!");
-          } else {
-              AiMessage(contractChatId, "임차인이 최종 계약서를 거절했습니다. 추가 협상이 필요합니다.");
-          }
-          return Map.of("accepted", isAccepted);
-      }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
@Override
public Map<String, Object> acceptFinalContract(
Long contractChatId, Long buyerId, Boolean isAccepted) {
if (!isUserInContractChat(contractChatId, buyerId)) {
throw new BusinessException(ChatErrorCode.CHAT_ROOM_ACCESS_DENIED);
}
ContractChat contractChat = contractChatMapper.findByContractChatId(contractChatId);
if (contractChat == null) {
throw new EntityNotFoundException("계약 채팅방을 찾을 수 없습니다: " + contractChatId);
}
Long ownerId = contractChat.getOwnerId();
if (!buyerId.equals(contractChat.getBuyerId())) {
throw new BusinessException(
ChatErrorCode.CHAT_ROOM_ACCESS_DENIED, "임차인만 확정 수락을 할 수 있습니다.");
}
String redisKey = "final-contract:request:" + contractChatId;
String storedOwnerId = stringRedisTemplate.opsForValue().get(redisKey);
if (storedOwnerId == null) {
throw new BusinessException(
ChatErrorCode.CONTRACT_END_REQUEST_NOT_FOUND, "확정 요청이 존재하지 않습니다.");
}
if (!storedOwnerId.equals(ownerId.toString())) {
throw new BusinessException(
ChatErrorCode.CONTRACT_END_REQUEST_INVALID, "확정 요청 정보가 유효하지 않습니다.");
}
stringRedisTemplate.delete(redisKey);
if (isAccepted) {
contractMongoRepository.clearSpecialContracts(contractChatId);
contractMongoRepository.saveSpecialContract(contractChatId);
contractChatMapper.updateStatus(contractChatId, ContractChat.ContractStatus.COMPLETE);
AiMessage(contractChatId, "임차인이 최종 계약서를 수락했습니다! 계약서 서명하러 갈께요!");
} else {
AiMessage(contractChatId, "임차인이 최종 계약서를 거절했습니다. 추가 협상이 필요합니다.");
}
return baseUrl + contractChatUrl + contractChatRoomId.toString() + param;
return Map.of("accepted", isAccepted);
}
// (removed duplicate acceptFinalContract method)
🤖 Prompt for AI Agents
In src/main/java/org/scoula/domain/chat/service/ContractChatServiceImpl.java
around lines 2868-2912, there is a duplicate method definition for
acceptFinalContract(Long, Long, Boolean) causing a compile error; remove this
redundant method (or merge any necessary logic into the existing
acceptFinalContract implementation) so only one method with that signature
remains, update callers if you moved code, and run mvn -DskipTests=false compile
to verify the duplicate is resolved.

Comment on lines +37 to +46
private Long getUserIdFromAuthentication(Authentication authentication) {
String currentUserEmail = authentication.getName();
Optional<User> currentUserOpt = userService.findByEmail(currentUserEmail);

if (currentUserOpt.isEmpty()) {
throw new BusinessException(ChatErrorCode.USER_NOT_FOUND);
}

return currentUserOpt.get().getUserId();
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Authentication null 가드 추가 필요

보안 설정/테스트 환경에 따라 authentication이 null일 수 있습니다. 현재는 null 시 NPE가 발생합니다. 인증 미존재 시 적절한 예외를 던지세요.

적용 diff:

       private Long getUserIdFromAuthentication(Authentication authentication) {
-          String currentUserEmail = authentication.getName();
+          if (authentication == null || !authentication.isAuthenticated()) {
+              throw new BusinessException(ChatErrorCode.USER_NOT_FOUND);
+          }
+          String currentUserEmail = authentication.getName();
           Optional<User> currentUserOpt = userService.findByEmail(currentUserEmail);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
private Long getUserIdFromAuthentication(Authentication authentication) {
String currentUserEmail = authentication.getName();
Optional<User> currentUserOpt = userService.findByEmail(currentUserEmail);
if (currentUserOpt.isEmpty()) {
throw new BusinessException(ChatErrorCode.USER_NOT_FOUND);
}
return currentUserOpt.get().getUserId();
}
private Long getUserIdFromAuthentication(Authentication authentication) {
if (authentication == null || !authentication.isAuthenticated()) {
throw new BusinessException(ChatErrorCode.USER_NOT_FOUND);
}
String currentUserEmail = authentication.getName();
Optional<User> currentUserOpt = userService.findByEmail(currentUserEmail);
if (currentUserOpt.isEmpty()) {
throw new BusinessException(ChatErrorCode.USER_NOT_FOUND);
}
return currentUserOpt.get().getUserId();
}
🤖 Prompt for AI Agents
In
src/main/java/org/scoula/domain/contract/controller/ContractControllerImpl.java
around lines 37 to 46, add a null guard for the incoming Authentication: if
authentication is null (or authentication.getName() is null/blank) throw a
BusinessException indicating missing authentication (use an existing error code
such as ChatErrorCode.UNAUTHENTICATED or create one if needed), and only then
call authentication.getName() and lookup the user; this prevents an NPE and
returns a clear business error when no authentication is present.

Comment on lines +233 to +254
@PostMapping("/specialContract/final-accept")
public ResponseEntity<ApiResponse<Map<String, Object>>> acceptFinalContract(
@PathVariable Long contractChatId,
@RequestBody FinalContractDeletionResponseDto responseDto,
Authentication authentication) {

try {
Long userId = getUserIdFromAuthentication(authentication);

Map<String, Object> result =
contractChatService.acceptFinalContract(
contractChatId, userId, responseDto.isAccepted());
return ResponseEntity.ok(ApiResponse.success(result));

} catch (BusinessException e) {
return ResponseEntity.badRequest().body(ApiResponse.error(e.getMessage()));
} catch (Exception e) {
log.error("최종 특약서 확정 수락 처리 중 오류 발생", e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(ApiResponse.error("서버 오류가 발생했습니다."));
}
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

수락 요청 본문 유효성 검증 및 null 가드 추가

responseDto가 null이거나 accepted가 누락되면 NPE 가능성이 있습니다. @Valid로 요청 유효성 검증을 활성화하고, 초기에 null 가드를 추가하세요. 또한 Map 대신 명시적 DTO 사용을 권장합니다.

적용 diff:

-      public ResponseEntity<ApiResponse<Map<String, Object>>> acceptFinalContract(
-              @PathVariable Long contractChatId,
-              @RequestBody FinalContractDeletionResponseDto responseDto,
-              Authentication authentication) {
+      public ResponseEntity<ApiResponse<Map<String, Object>>> acceptFinalContract(
+              @PathVariable Long contractChatId,
+              @Valid @RequestBody FinalContractDeletionResponseDto responseDto,
+              Authentication authentication) {
 
           try {
               Long userId = getUserIdFromAuthentication(authentication);
 
+              if (responseDto == null) {
+                  return ResponseEntity.badRequest().body(ApiResponse.error("요청 본문이 비어 있습니다."));
+              }
               Map<String, Object> result =
                       contractChatService.acceptFinalContract(
                               contractChatId, userId, responseDto.isAccepted());

추가 제안:

  • 반환 타입을 FinalContractAcceptResultDto 같은 명시적 DTO로 변경하면 Swagger 문서화와 프론트 연동이 수월해집니다.
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
@PostMapping("/specialContract/final-accept")
public ResponseEntity<ApiResponse<Map<String, Object>>> acceptFinalContract(
@PathVariable Long contractChatId,
@RequestBody FinalContractDeletionResponseDto responseDto,
Authentication authentication) {
try {
Long userId = getUserIdFromAuthentication(authentication);
Map<String, Object> result =
contractChatService.acceptFinalContract(
contractChatId, userId, responseDto.isAccepted());
return ResponseEntity.ok(ApiResponse.success(result));
} catch (BusinessException e) {
return ResponseEntity.badRequest().body(ApiResponse.error(e.getMessage()));
} catch (Exception e) {
log.error("최종 특약서 확정 수락 처리 중 오류 발생", e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(ApiResponse.error("서버 오류가 발생했습니다."));
}
}
@PostMapping("/specialContract/final-accept")
public ResponseEntity<ApiResponse<Map<String, Object>>> acceptFinalContract(
@PathVariable Long contractChatId,
@Valid @RequestBody FinalContractDeletionResponseDto responseDto,
Authentication authentication) {
try {
Long userId = getUserIdFromAuthentication(authentication);
if (responseDto == null) {
return ResponseEntity.badRequest().body(ApiResponse.error("요청 본문이 비어 있습니다."));
}
Map<String, Object> result =
contractChatService.acceptFinalContract(
contractChatId, userId, responseDto.isAccepted());
return ResponseEntity.ok(ApiResponse.success(result));
} catch (BusinessException e) {
return ResponseEntity.badRequest().body(ApiResponse.error(e.getMessage()));
} catch (Exception e) {
log.error("최종 특약서 확정 수락 처리 중 오류 발생", e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(ApiResponse.error("서버 오류가 발생했습니다."));
}
}

Comment on lines +70 to +90
ResponseEntity<LegalityDTO> response =
restTemplate.exchange(url, HttpMethod.POST, requestEntity, LegalityDTO.class);
LegalityDTO res = response.getBody();
assert res != null;

String resStr;
try {
ObjectMapper objectMapper = new ObjectMapper();
resStr = objectMapper.writeValueAsString(res);
} catch (Exception ex) {
resStr = res.toString();
}
// Remove newlines and carriage returns
resStr = resStr.replaceAll("[\\r\\n]", " ");
log.warn("AI 응답 값 확인: {}", resStr);
log.warn("AI 응답 헤더 확인: {}", response.getStatusCode());
if (response.getStatusCode().is2xxSuccessful() && response.getBody() != null) {
return response.getBody();
} else {
// Sanitize response body before logging to prevent log injection
String responseBodyStr;
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

AI 응답 본문 null 시 NPE 발생 가능 — null 가드 후 로깅/분기 처리

res = response.getBody() 직후 res를 직렬화/로깅하고 있어, 본문이 null인 경우 NPE가 발생합니다. 응답 코드 및 본문 null 여부를 먼저 점검하고, 그 다음 로깅/반환하세요.

적용 diff:

-              ResponseEntity<LegalityDTO> response =
-                      restTemplate.exchange(url, HttpMethod.POST, requestEntity, LegalityDTO.class);
-              LegalityDTO res = response.getBody();
-              assert res != null;
-
-              String resStr;
-              try {
-                  ObjectMapper objectMapper = new ObjectMapper();
-                  resStr = objectMapper.writeValueAsString(res);
-              } catch (Exception ex) {
-                  resStr = res.toString();
-              }
-              // Remove newlines and carriage returns
-              resStr = resStr.replaceAll("[\\r\\n]", " ");
-              log.warn("AI 응답 값 확인: {}", resStr);
-              log.warn("AI 응답 헤더 확인: {}", response.getStatusCode());
-              if (response.getStatusCode().is2xxSuccessful() && response.getBody() != null) {
-                  return response.getBody();
-              } else {
+              ResponseEntity<LegalityDTO> response =
+                      restTemplate.exchange(url, HttpMethod.POST, requestEntity, LegalityDTO.class);
+              if (!response.getStatusCode().is2xxSuccessful() || response.getBody() == null) {
                   // Sanitize response body before logging to prevent log injection
                   String responseBodyStr;
                   try {
                       ObjectMapper objectMapper = new ObjectMapper();
                       responseBodyStr = objectMapper.writeValueAsString(response.getBody());
                   } catch (Exception ex) {
                       responseBodyStr = String.valueOf(response.getBody());
                   }
                   // Remove newlines and carriage returns
                   responseBodyStr = responseBodyStr.replaceAll("[\\p{Cntrl}]", " ");
                   log.error("AI server error response (sanitized): {}", responseBodyStr);
                   throw new BusinessException(ContractException.CONTRACT_AI_SERVER_ERROR);
-              }
+              }
+
+              // 성공 응답 로깅 (민감정보 과다 노출 방지: 필요시 필드 마스킹/길이 제한)
+              LegalityDTO res = response.getBody();
+              String resStr;
+              try {
+                  ObjectMapper objectMapper = new ObjectMapper();
+                  resStr = objectMapper.writeValueAsString(res);
+              } catch (Exception ex) {
+                  resStr = String.valueOf(res);
+              }
+              resStr = resStr.replaceAll("[\\r\\n]", " ");
+              log.info("AI 응답 코드: {}", response.getStatusCode());
+              log.debug("AI 응답 본문(일부): {}", resStr.length() > 2000 ? resStr.substring(0, 2000) + "..." : resStr);
+              return res;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
ResponseEntity<LegalityDTO> response =
restTemplate.exchange(url, HttpMethod.POST, requestEntity, LegalityDTO.class);
LegalityDTO res = response.getBody();
assert res != null;
String resStr;
try {
ObjectMapper objectMapper = new ObjectMapper();
resStr = objectMapper.writeValueAsString(res);
} catch (Exception ex) {
resStr = res.toString();
}
// Remove newlines and carriage returns
resStr = resStr.replaceAll("[\\r\\n]", " ");
log.warn("AI 응답 값 확인: {}", resStr);
log.warn("AI 응답 헤더 확인: {}", response.getStatusCode());
if (response.getStatusCode().is2xxSuccessful() && response.getBody() != null) {
return response.getBody();
} else {
// Sanitize response body before logging to prevent log injection
String responseBodyStr;
ResponseEntity<LegalityDTO> response =
restTemplate.exchange(url, HttpMethod.POST, requestEntity, LegalityDTO.class);
if (!response.getStatusCode().is2xxSuccessful() || response.getBody() == null) {
// Sanitize response body before logging to prevent log injection
String responseBodyStr;
try {
ObjectMapper objectMapper = new ObjectMapper();
responseBodyStr = objectMapper.writeValueAsString(response.getBody());
} catch (Exception ex) {
responseBodyStr = String.valueOf(response.getBody());
}
// Remove newlines and other control characters
responseBodyStr = responseBodyStr.replaceAll("[\\p{Cntrl}]", " ");
log.error("AI server error response (sanitized): {}", responseBodyStr);
throw new BusinessException(ContractException.CONTRACT_AI_SERVER_ERROR);
}
// 성공 응답 로깅 (민감정보 과다 노출 방지: 필요시 필드 마스킹/길이 제한)
LegalityDTO res = response.getBody();
String resStr;
try {
ObjectMapper objectMapper = new ObjectMapper();
resStr = objectMapper.writeValueAsString(res);
} catch (Exception ex) {
resStr = String.valueOf(res);
}
resStr = resStr.replaceAll("[\\r\\n]", " ");
log.info("AI 응답 코드: {}", response.getStatusCode());
log.debug("AI 응답 본문(일부): {}", resStr.length() > 2000 ? resStr.substring(0, 2000) + "..." : resStr);
return res;
🤖 Prompt for AI Agents
In src/main/java/org/scoula/domain/contract/service/ContractFixService.java
around lines 70 to 90, the code serializes and logs response.getBody()
immediately which can cause an NPE if the body is null; first check
response.getStatusCode() and whether response.getBody() is null before
attempting to serialize or call methods on it, only serialize/log the body when
non-null (otherwise log a safe message like "response body is null"), and ensure
the method branches to handle non-2xx or null-body responses (log a sanitized
message and return or throw as appropriate) so no null dereference occurs.

Comment on lines +365 to 380
ChatRoom chatRoom =
chatRoomMapper.findByUserAndHome(
contractChat.getOwnerId(),
contractChat.getBuyerId(),
contractChat.getHomeId());
String contractChatUrls =
URL
+ precontractUrl
+ (contractChatId.toString())
+ ownerUrl
+ "&homeId="
+ (contractChat.getHomeId().toString());
ChatMessageRequestDto linkMessages =
ChatMessageRequestDto.builder()
.chatRoomId(contractChatId)
.chatRoomId(chatRoom.getChatRoomId())
.senderId(contractChat.getBuyerId())
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

⚠️ Potential issue

chatRoom 조회 NPE 가능성 — 널 방어 및 예외 처리 필요

chatRoomMapper.findByUserAndHome(...)가 null을 반환하면 chatRoom.getChatRoomId()에서 NPE가 발생합니다. contractChat이 null이어도 위 호출들이 모두 실패합니다. 실패 시 사용자 친화적 에러로 전환하세요.

적용 diff:

-          ChatRoom chatRoom =
-                  chatRoomMapper.findByUserAndHome(
-                          contractChat.getOwnerId(),
-                          contractChat.getBuyerId(),
-                          contractChat.getHomeId());
+          ChatRoom chatRoom =
+                  chatRoomMapper.findByUserAndHome(
+                          contractChat.getOwnerId(),
+                          contractChat.getBuyerId(),
+                          contractChat.getHomeId());
+          if (chatRoom == null) {
+              log.warn("채팅방을 찾을 수 없습니다. ownerId={}, buyerId={}, homeId={}",
+                      contractChat.getOwnerId(), contractChat.getBuyerId(), contractChat.getHomeId());
+              throw new BusinessException(PreContractErrorCode.TENANT_SELECT);
+          }

참고:

  • contractChatMapper.findByContractChatId(contractChatId)가 null일 가능성도 있습니다. 해당 부분도 null 체크 후 적절한 BusinessException으로 변환하는 것을 권장합니다.
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
ChatRoom chatRoom =
chatRoomMapper.findByUserAndHome(
contractChat.getOwnerId(),
contractChat.getBuyerId(),
contractChat.getHomeId());
String contractChatUrls =
URL
+ precontractUrl
+ (contractChatId.toString())
+ ownerUrl
+ "&homeId="
+ (contractChat.getHomeId().toString());
ChatMessageRequestDto linkMessages =
ChatMessageRequestDto.builder()
.chatRoomId(contractChatId)
.chatRoomId(chatRoom.getChatRoomId())
.senderId(contractChat.getBuyerId())
ChatRoom chatRoom =
chatRoomMapper.findByUserAndHome(
contractChat.getOwnerId(),
contractChat.getBuyerId(),
contractChat.getHomeId());
if (chatRoom == null) {
log.warn("채팅방을 찾을 수 없습니다. ownerId={}, buyerId={}, homeId={}",
contractChat.getOwnerId(), contractChat.getBuyerId(), contractChat.getHomeId());
throw new BusinessException(PreContractErrorCode.TENANT_SELECT);
}
String contractChatUrls =
URL
precontractUrl
contractChatId.toString()
ownerUrl
"&homeId="
contractChat.getHomeId().toString();
ChatMessageRequestDto linkMessages =
ChatMessageRequestDto.builder()
.chatRoomId(chatRoom.getChatRoomId())
.senderId(contractChat.getBuyerId())

Comment on lines +61 to +135
@Override
public void configureClientInboundChannel(ChannelRegistration registration) {
registration.interceptors(
new ChannelInterceptor() {
@Override
public Message<?> preSend(Message<?> message, MessageChannel channel) {
StompHeaderAccessor accessor =
MessageHeaderAccessor.getAccessor(
message, StompHeaderAccessor.class);

if (StompCommand.CONNECT.equals(accessor.getCommand())) {
log.info("🔐 WebSocket 연결 시도 - 헤더 확인");

// 헤더에서 인증 정보 확인
String authHeader = accessor.getFirstNativeHeader("Authorization");
String userId = accessor.getFirstNativeHeader("X-User-Id");

log.info(
"🔍 받은 헤더 - Authorization: {}, X-User-Id: {}",
authHeader != null ? "있음" : "없음",
userId);

// JWT 토큰에서 사용자 ID 추출
if (authHeader != null && authHeader.startsWith("Bearer ")) {
try {
String token = authHeader.substring(7);
// 간단한 JWT 파싱 (실제로는 JwtUtil 사용 권장)
String[] parts = token.split("\\.");
if (parts.length == 3) {
String payload =
new String(
java.util.Base64.getDecoder()
.decode(parts[1]));
// 페이로드에서 sub (사용자 이메일) 추출
if (payload.contains("\"sub\"")) {
String[] subParts = payload.split("\"sub\":\"");
if (subParts.length > 1) {
String userEmail = subParts[1].split("\"")[0];
// Principal 설정
UsernamePasswordAuthenticationToken auth =
new UsernamePasswordAuthenticationToken(
userEmail,
null,
Collections.emptyList());
accessor.setUser(auth);
log.info(
"✅ WebSocket 인증 성공 (JWT) - User: {}",
userEmail);
}
}
}
} catch (Exception e) {
log.error("❌ JWT 토큰 파싱 실패: {}", e.getMessage());
}
}

// X-User-Id 헤더가 있으면 사용 (백업)
if (accessor.getUser() == null && userId != null && !userId.isEmpty()) {
UsernamePasswordAuthenticationToken auth =
new UsernamePasswordAuthenticationToken(
userId, null, Collections.emptyList());
accessor.setUser(auth);
}

log.info(
"🔐 최종 Principal 상태: {}",
accessor.getUser() != null
? accessor.getUser().getName()
: "null");
}

return message;
}
});
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

STOMP CONNECT 인증 로직의 심각한 보안 취약점: 임의 토큰/헤더로 사용자 가장 가능

현재 구현은 다음 이유로 실질적인 인증이 이뤄지지 않습니다.

  • JWT를 단순 Base64 디코딩 후 문자열 파싱해 sub만 추출합니다. 서명/alg/exp 검증이 전혀 없습니다. 누구나 임의 페이로드를 넣은 토큰으로 가장 가능합니다.
  • JWT는 Base64URL 인코딩을 사용하지만 Base64.getDecoder()를 사용하고 있어 토큰 구성에 따라 디코딩 에러가 발생할 수 있습니다.
  • JWT 파싱 실패 시 X-User-Id 헤더를 그대로 Principal로 사용합니다. 이 역시 무단 가장을 허용합니다.
  • MessageHeaderAccessor.getAccessor(...)가 null일 수 있어 NPE 위험이 있습니다.

권장 수정:

  1. 인증된 Authentication을 생성/검증하는 기존 JWT 유틸/프로바이더(예: JwtTokenProvider, JwtUtil, 혹은 SecurityConfig에서 사용하는 컴포넌트)를 주입해 서명/만료/클레임을 검증한 후 Principal을 설정하세요.
  2. X-User-Id 백업 경로는 제거하세요. 필요 시 로컬 프로파일에서만 활성화하시기 바랍니다.
  3. StompHeaderAccessor null 가드를 추가하세요.

예시 패치(핵심 블록 교체 및 NPE 가드):

       public void configureClientInboundChannel(ChannelRegistration registration) {
           registration.interceptors(
                   new ChannelInterceptor() {
                       @Override
                       public Message<?> preSend(Message<?> message, MessageChannel channel) {
-                          StompHeaderAccessor accessor =
-                                  MessageHeaderAccessor.getAccessor(
-                                          message, StompHeaderAccessor.class);
+                          StompHeaderAccessor accessor =
+                                  MessageHeaderAccessor.getAccessor(message, StompHeaderAccessor.class);
+                          if (accessor == null) {
+                              return message;
+                          }

                           if (StompCommand.CONNECT.equals(accessor.getCommand())) {
                               log.info("🔐 WebSocket 연결 시도 - 헤더 확인");

                               // 헤더에서 인증 정보 확인
                               String authHeader = accessor.getFirstNativeHeader("Authorization");
-                              String userId = accessor.getFirstNativeHeader("X-User-Id");
-
-                              log.info(
-                                      "🔍 받은 헤더 - Authorization: {}, X-User-Id: {}",
-                                      authHeader != null ? "있음" : "없음",
-                                      userId);
+                              log.info("🔍 받은 헤더 - Authorization: {}", authHeader != null ? "있음" : "없음");

-                              // JWT 토큰에서 사용자 ID 추출
-                              if (authHeader != null && authHeader.startsWith("Bearer ")) {
-                                  try {
-                                      String token = authHeader.substring(7);
-                                      // 간단한 JWT 파싱 (실제로는 JwtUtil 사용 권장)
-                                      String[] parts = token.split("\\.");
-                                      if (parts.length == 3) {
-                                          String payload =
-                                                  new String(
-                                                          java.util.Base64.getDecoder()
-                                                                  .decode(parts[1]));
-                                          // 페이로드에서 sub (사용자 이메일) 추출
-                                          if (payload.contains("\"sub\"")) {
-                                              String[] subParts = payload.split("\"sub\":\"");
-                                              if (subParts.length > 1) {
-                                                  String userEmail = subParts[1].split("\"")[0];
-                                                  // Principal 설정
-                                                  UsernamePasswordAuthenticationToken auth =
-                                                          new UsernamePasswordAuthenticationToken(
-                                                                  userEmail,
-                                                                  null,
-                                                                  Collections.emptyList());
-                                                  accessor.setUser(auth);
-                                                  log.info(
-                                                          "✅ WebSocket 인증 성공 (JWT) - User: {}",
-                                                          userEmail);
-                                              }
-                                          }
-                                      }
-                                  } catch (Exception e) {
-                                      log.error("❌ JWT 토큰 파싱 실패: {}", e.getMessage());
-                                  }
-                              }
-
-                              // X-User-Id 헤더가 있으면 사용 (백업)
-                              if (accessor.getUser() == null && userId != null && !userId.isEmpty()) {
-                                  UsernamePasswordAuthenticationToken auth =
-                                          new UsernamePasswordAuthenticationToken(
-                                                  userId, null, Collections.emptyList());
-                                  accessor.setUser(auth);
-                              }
+                              if (authHeader != null && authHeader.startsWith("Bearer ")) {
+                                  try {
+                                      String token = authHeader.substring(7);
+                                      // 프로젝트의 JWT 컴포넌트를 활용해 서명/만료 검증 및 Authentication 생성
+                                      org.springframework.security.core.Authentication auth =
+                                              jwtTokenProvider.getAuthentication(token);
+                                      if (auth != null && auth.isAuthenticated()) {
+                                          accessor.setUser(auth);
+                                          log.info("✅ WebSocket 인증 성공 (JWT) - User: {}", auth.getName());
+                                      } else {
+                                          log.warn("❌ JWT 검증 실패: 인증 불가");
+                                      }
+                                  } catch (Exception e) {
+                                      log.warn("❌ JWT 검증 중 예외 발생", e);
+                                  }
+                              } else {
+                                  log.warn("❌ Authorization 헤더 없음 또는 Bearer 형식 아님");
+                              }

                               log.info(
                                       "🔐 최종 Principal 상태: {}",
                                       accessor.getUser() != null
                                               ? accessor.getUser().getName()
                                               : "null");
                           }

                           return message;
                       }
                   });
       }

추가로 클래스 외부 변경(의존성 주입) 예시:

// 필드 추가
@Autowired
private JwtTokenProvider jwtTokenProvider; // 실제 프로젝트의 JWT 유틸/프로바이더 타입으로 교체

Jwt 유틸/프로바이더의 실제 타입/메서드 시그니처를 알려주시면, 프로젝트에 맞춘 정확한 패치까지 만들어드리겠습니다.

🤖 Prompt for AI Agents
In src/main/java/org/scoula/global/websocket/config/WebSocketConfig.java around
lines 61 to 135, the STOMP CONNECT handler performs insecure, incomplete JWT
handling (Base64 decode + string parsing), lacks signature/expiry/alg
verification, uses Base64 instead of Base64URL decoding, accepts a fallback
X-User-Id header (allowing impersonation), and risks NPE from a null
StompHeaderAccessor; replace the manual parsing with an injected, project JWT
verifier/provider (e.g., JwtTokenProvider or JwtUtil) that validates signature,
algorithm and expiry and returns an authenticated Authentication; guard against
a null StompHeaderAccessor before using it; remove or restrict the X-User-Id
fallback (only enable under a secure dev profile if needed); and log errors with
safe, non-sensitive details while rejecting unauthenticated CONNECT attempts by
not setting the Principal when verification fails.

@MeongW MeongW merged commit a1aefef into develop Aug 18, 2025
5 of 7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Feat] 이슈 제목

3 participants